Skip to content

feat: web dashboard core infrastructure (PR 1 of 2)#342

Closed
Aureliolo wants to merge 12 commits intomainfrom
feat/web-dashboard
Closed

feat: web dashboard core infrastructure (PR 1 of 2)#342
Aureliolo wants to merge 12 commits intomainfrom
feat/web-dashboard

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

Core infrastructure for the Vue 3 + PrimeVue + Tailwind CSS web dashboard (PR 1 of 2). This PR establishes the foundation; page views and feature components follow in PR 2 (feat/web-dashboard-pages).

  • API layer: Axios client with JWT interceptor, response envelope unwrapping, encodeURIComponent on all dynamic path segments; endpoint modules for auth, agents, tasks, budget, approvals, messages, analytics, company, providers, health
  • State management: 10 Pinia stores (auth, agents, tasks, budget, messages, approvals, websocket, analytics, company, providers) with WebSocket real-time event handling
  • Composables: useAuth (token lifecycle), usePolling (interval management), useOptimisticUpdate (rollback on failure)
  • Router: Vue Router with auth guards (login/setup/placeholder home), lazy-loaded routes
  • Types: Full TypeScript type definitions mirroring backend Pydantic models (verified against source)
  • Components: Common (ErrorBoundary, LoadingSkeleton, PageHeader, EmptyState, StatusBadge) and Layout (AppShell, Sidebar, Topbar, ConnectionStatus)
  • Auth views: LoginPage, SetupPage
  • Utils: Constants, error helpers (status-code mapping), formatters
  • Styles: Tailwind CSS v4, PrimeVue Aura dark theme preset
  • Config: Vite, TypeScript (strict), ESLint, Vitest, env.d.ts
  • Docker: nginx config for SPA routing + API/WS proxy, multi-stage Dockerfile
  • CI: Added dashboard-build (Vite production build) and dashboard-audit (npm audit critical+high) jobs to CI pipeline, gating ci-pass
  • Tests: 145 unit tests across 17 test files (stores, composables, utils, components, router guards, API client)

Review history

This code went through 3 rounds of local agent review (code-reviewer, type-design-analyzer, silent-failure-hunter, pr-test-analyzer, comment-analyzer, security-reviewer) plus 2 rounds of external reviewer feedback (CodeRabbit, Copilot, Gemini, Greptile, Qodo, Ellipsis). 137+ findings addressed across all rounds.

Test plan

  • npm --prefix web run lint — ESLint passes
  • npm --prefix web run type-check — vue-tsc passes
  • npm --prefix web run build — Vite production build succeeds
  • npm --prefix web run test — 145 tests pass
  • CI dashboard-lint, dashboard-test, dashboard-build, dashboard-audit all green

Part of #233 (page views in PR 2: #340)

🤖 Generated with Claude Code

Replace placeholder Coming Soon page with production-ready SPA dashboard.

- Vue 3.5 + Vite + TypeScript, Pinia stores, PrimeVue unstyled + Tailwind
- 13 views: Dashboard, Tasks (Kanban+List), Approvals, Agents, Budget,
  Messages, Org Chart, Settings, Login/Setup, stub pages
- Real-time WebSocket integration with exponential backoff reconnect
- ECharts spending charts, vue-flow org chart, drag-and-drop Kanban
- API client with JWT interceptor and envelope unwrapping
- 77 tests across 16 test files (stores, utils, components, API client)
- Fix nginx WebSocket proxy path (/ws -> /api/v1/ws), update CSP
- Multi-stage Docker build (Node builder -> nginx runtime)
- CI: dashboard-lint + dashboard-test jobs added to ci-pass gate
…r handling

Pre-reviewed by 5 agents (code-reviewer, python-reviewer, pr-test-analyzer,
silent-failure-hunter, security-reviewer), 47 findings addressed.

Key changes:
- Align all TypeScript interfaces with backend Pydantic models (AgentConfig,
  Task, CostRecord, BudgetConfig, PersonalityConfig)
- Add token expiry persistence, client-side rate limiting on login/setup
- Fix WebSocket reconnection (pending subscriptions queue, max retries)
- Fix Kanban drag-and-drop (@EnD@add on receiving column)
- Add global error handler, unhandled rejection catcher
- Add eslint-plugin-security, HSTS header, remove plaintext ws: from CSP
- Fix auth timer leak, budget store error handling, WS cleanup on unmount
- Update docs (CLAUDE.md, README, roadmap, design spec, user guide)
Fixes from code-reviewer, silent-failure-hunter, comment-analyzer,
type-design-analyzer, docs-consistency, and external reviewers
(CodeRabbit, Copilot, Gemini, CodeQL).

Key changes:
- Align types with backend (ToolAccessLevel, decision_reason, optional fields)
- Harden WebSocket store (race condition, log sanitization, reconnect state)
- Consistent error handling via getErrorMessage() across all stores
- Fix optimistic update rollback and polling unmount safety
- Add keyboard accessibility to TaskCard and ApprovalCard
- Fix auth guard to use route meta instead of hardcoded paths
- Fix sidebar route matching prefix collision
- Add WS memory caps (500 records/messages)
- Prevent form submission bypass in TaskCreateDialog
- Disconnect WebSocket on logout
- Gate global error handlers to DEV mode
- Fix CSP connect-src for WebSocket protocols
- Update CLAUDE.md package structure and commands
- Update docs (getting_started, user_guide) for web dashboard
- Remove dead useWebSocket composable
- Fix SpendingSummary sort order
…sholds

CI Dashboard Test job was broken — it ran `npm test -- --coverage` but
@vitest/coverage-v8 was never installed. Added the dependency and removed
the 80% coverage thresholds since the dashboard is new (~15% coverage).
Thresholds can be reintroduced incrementally as test coverage grows.
Stores:
- Fix tasksByStatus O(n²) spread → use push for O(n)
- Schedule auth token expiry timer on page restore
- Fix agent total counter drift on duplicate fired events
- Clear error before approve/reject/fetchConfig/fetchDepartments
- Filter WS messages by active channel

Views:
- Make agentNames a computed (was stale ref)
- Fix SettingsPage loading stuck on fetch failure
- Fix OrgChart retryFetch unhandled promise, use departmentsLoading
- Pre-index agents in OrgChart for O(1) lookup
- Encode agent names in URL path segments
- BudgetPanel retry fetches both config and records
- Gate DashboardPage console.error to DEV

Components:
- Remove lazy pagination from TaskListView (client-side)
- Add keyboard accessibility to AgentCard (role, tabindex, space)
- Add space key handler to TaskCard and ApprovalCard
- Add empty tools state in AgentMetrics
- Guard SpendingChart tooltip for empty params
- Smart auto-scroll in MessageList (only if near bottom)
- Add role="alert" to ErrorBoundary
- Wrap Topbar logout in try-catch
- Use replaceAll for status underscores in TaskDetailPanel

API:
- Encode all dynamic path segments (agents, approvals, budget, providers)

Utils:
- Validate data.error is string at runtime in getErrorMessage
- Handle future dates in formatRelativeTime
- Add Firefox scrollbar support (scrollbar-width/scrollbar-color)

Tests:
- Fix formatRelativeTime test flakiness (use fixed offset)
- Add future date and negative currency test cases
Security:
- Add encodeURIComponent on all taskId and department name path segments
- Fix type mismatches: ProviderConfig, ProviderModelConfig, Channel,
  Message, ApprovalItem now match backend Pydantic models
- Add missing Message fields (to, type, priority, attachments, metadata)
- Remove phantom fields (ProviderConfig.name/enabled, ApprovalItem.ttl_seconds)
- Use literal union for CostRecord.call_category

Error handling:
- Remove empty catch block in OrgChartPage (violates project rules)
- Always log errors in production (DashboardPage, main.ts)
- Use getErrorMessage in AgentDetailPage instead of generic string
- Show agent fetch error in TaskBoardPage ErrorBoundary
- Add ErrorBoundary to SettingsPage for company/provider errors
- Handle fetchUser failure in login by clearing half-auth state
- Redirect to login on token expiry
- Add validation on WebSocket subscription ack data
- Add try/catch on WebSocket send for race condition
- Await fire-and-forget fetches in ApprovalQueuePage and MessageFeedPage
- Add 422/429 error message handling

Performance:
- Move agentIndex Map creation outside inner loop in OrgChartPage

Tests (79 → 131):
- Rewrite useOptimisticUpdate tests to test actual composable
- Rewrite usePolling tests to test actual composable
- Add auth store async tests (login, setup, fetchUser, changePassword)
- Add auth guard tests (4 scenarios)
- Add task store CRUD tests (fetchTasks, createTask, updateTask, etc.)
- Add approval store approve/reject tests
- Add WebSocket store tests
- Add 422/429 error message tests
Remove page views and feature components to separate branch
(feat/web-dashboard-pages) for independent review. Core PR retains:
- API client, types, endpoint modules
- Pinia stores (auth, agents, tasks, budget, messages, approvals, websocket, analytics, company, providers)
- Composables (useAuth, usePolling, useOptimisticUpdate)
- Common/layout components, auth views (Login, Setup)
- Router with auth guards (placeholder home route)
- Utils, styles, all project config
- 128 unit tests across 17 test files
- CI: add dashboard-build and dashboard-audit (npm audit) jobs
- Fix: add @types/node and tsconfig.node.json types for build
Fixes from Gemini, Greptile, Copilot, GitHub Advanced Security:

Bug fixes:
- auth store: setup() now calls fetchUser() instead of constructing
  stale user object with empty id and hardcoded role
- messages store: total counter only increments for messages matching
  activeChannel filter, preventing total/visible count divergence
- tasks store: WS task.created events skip append when filters are
  active, preventing off-filter tasks from appearing in filtered views
- websocket store: pending subscriptions deduplicated to prevent
  duplicate subscribe messages on reconnect
- websocket store: active subscriptions tracked and auto-re-subscribed
  on reconnect to maintain real-time updates after transient disconnect

Security:
- websocket store: sanitize user-provided values in log output to
  prevent log injection (newline stripping)
- nginx CSP: remove blanket ws:/wss: from connect-src, use 'self' only
  (same-origin WS via nginx proxy; CSP Level 3 covers ws/wss)
- nginx CSP: document why style-src 'unsafe-inline' is required
  (PrimeVue injects dynamic inline styles)
- Dockerfile: pin node:22-alpine by digest for reproducible builds
- Dockerfile: run builder stage as non-root user for defense-in-depth

Docs:
- roadmap: change web dashboard status from "implemented" to
  "in progress" (PR 1 of 2)
- README: update status to reflect dashboard foundation merged,
  pages pending

Tests (134 total, +6 new):
- websocket tests rewritten with proper assertions: event dispatch via
  onmessage, wildcard handlers, malformed JSON, subscription ack,
  reconnect exhaustion (drives 20 failed attempts), auto-re-subscribe,
  log sanitization, send failure queuing
- auth tests: setup() now tests fetchUser() call and failure path
- messages tests: verify total not incremented for filtered messages
- tasks tests: verify WS events skipped when filters active
Implements all valid findings from CodeRabbit, Copilot, Gemini, Greptile,
Qodo, and Ellipsis across 38 files:

- Fix PaginatedResponse error branch typing (data: null, pagination: null)
- SPA-friendly 401 handling (dynamic router import vs window.location)
- setTimeout-based polling to prevent overlapping async calls
- Fetch generation counter to prevent stale concurrent overwrites
- Stable JSON serialization for WS subscription dedup keys
- WebSocket unsubscribe removes from activeSubscriptions
- Silent auth clearing detection in setup/login flows
- Broader control character stripping for log injection prevention
- Provider secret stripping (api_key removed from frontend state)
- NaN guards in date formatters, error message sanitization
- Accessibility: aria-hidden, role="alert", aria-label on nav
- HMR timer cleanup, favicon, meta description, nginx WS timeouts
- Split company store errors, explicit return types on endpoints
- 23 new/updated tests covering all behavioral changes
Copilot AI review requested due to automatic review settings March 13, 2026 08:11
}

if (msg.error) {
console.error('WebSocket error:', sanitizeLogValue(msg.error))

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 22 days ago

In general, to fix log injection, any user-controlled value must be sanitized before logging: remove or normalize line breaks and control characters, and ideally make user-controlled pieces clearly delimited. If logs could be rendered as HTML, additional HTML encoding would be needed.

In this codebase, the best fix without changing behavior is to strengthen sanitizeLogValue so that:

  • All newline-like characters are removed or normalized.
  • We continue stripping ASCII control chars and truncating long values.
  • Optionally, we replace remaining suspicious whitespace with spaces so that one logged value cannot visually masquerade as multiple entries.

We do not need to change the calls at line 91 or 99; we improve the helper they already use. Concretely, in web/src/stores/websocket.ts, modify sanitizeLogValue (lines 6–9) so that it:

  • Converts the value to string as before.
  • Replaces ASCII control chars (already done).
  • Also removes \r, \n, and Unicode line/paragraph separators \u2028 and \u2029 and other vertical whitespace that might still be interpreted as line breaks.
  • Optionally collapses consecutive whitespace to a single space, ensuring a compact, single-line log-safe string.

No new imports are needed; this is pure string/regex handling in TypeScript.

Suggested changeset 1
web/src/stores/websocket.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/web/src/stores/websocket.ts b/web/src/stores/websocket.ts
--- a/web/src/stores/websocket.ts
+++ b/web/src/stores/websocket.ts
@@ -3,9 +3,16 @@
 import type { WsChannel, WsEvent, WsEventHandler } from '@/api/types'
 import { WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY, WS_MAX_RECONNECT_ATTEMPTS } from '@/utils/constants'
 
-/** Strip all control characters and truncate for safe logging. */
+/** Strip control characters/newlines and truncate for safe single-line logging. */
 function sanitizeLogValue(value: unknown, max = 200): string {
-  return String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, max)
+  return String(value)
+    // Replace ASCII control characters with spaces
+    .replace(/[\x00-\x1f\x7f]/g, ' ')
+    // Remove common newline-like characters (CR, LF, Unicode LS/PS, vertical tab, form feed)
+    .replace(/[\r\n\u2028\u2029\v\f]/g, '')
+    // Collapse remaining whitespace to single spaces
+    .replace(/\s+/g, ' ')
+    .slice(0, max)
 }
 
 /** Build a stable deduplication key for a subscription (sorted channels + sorted filter keys). */
EOF
@@ -3,9 +3,16 @@
import type { WsChannel, WsEvent, WsEventHandler } from '@/api/types'
import { WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY, WS_MAX_RECONNECT_ATTEMPTS } from '@/utils/constants'

/** Strip all control characters and truncate for safe logging. */
/** Strip control characters/newlines and truncate for safe single-line logging. */
function sanitizeLogValue(value: unknown, max = 200): string {
return String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, max)
return String(value)
// Replace ASCII control characters with spaces
.replace(/[\x00-\x1f\x7f]/g, ' ')
// Remove common newline-like characters (CR, LF, Unicode LS/PS, vertical tab, form feed)
.replace(/[\r\n\u2028\u2029\v\f]/g, '')
// Collapse remaining whitespace to single spaces
.replace(/\s+/g, ' ')
.slice(0, max)
}

/** Build a stable deduplication key for a subscription (sorted channels + sorted filter keys). */
Copilot is powered by AI and may make mistakes. Always verify output.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 13, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4ed5d224-9c94-4d16-adb2-e14b443c0937

📥 Commits

Reviewing files that changed from the base of the PR and between 2319dc2 and bee3786.

📒 Files selected for processing (3)
  • web/nginx.conf
  • web/src/stores/auth.ts
  • web/src/stores/websocket.ts

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Vue 3 SPA web dashboard with login/setup flows, global layout, navigation, and placeholder home
    • Real-time updates via WebSockets, connection status indicators, and background health polling
    • UI primitives: toasts, confirm dialogs, empty states, loading skeletons, status badges, formatting and theme tokens
  • Documentation

    • Expanded docs and README with dashboard tech stack, dev commands, and getting-started guidance
  • Infrastructure

    • Multi-stage Docker image and nginx updates (WebSocket proxy)
    • CI extended to lint, type-check, test, build, and audit the dashboard
    • Added web env example and updated git/docker ignore rules

Walkthrough

Adds a full Vue 3 web dashboard scaffold (Vite + TypeScript) including a frontend build, Docker/nginx changes, CI jobs, API client/types/endpoints, Pinia stores, composables, components/views, styles, extensive Vitest tests, and documentation + ignore updates.

Changes

Cohort / File(s) Summary
Repo ignores & CI
/.dockerignore, /.gitignore, .github/workflows/ci.yml
Ignore web build artifacts and env; add dashboard CI jobs (lint, type-check, test, build, audit) and include their results in the ci-pass aggregation.
Docker build & nginx
docker/web/Dockerfile, web/nginx.conf
Multi-stage Docker build for the web app and nginx runtime; nginx adds CSP/HSTS and a dedicated /api/v1/ws WebSocket proxy ordered before generic /api/.
Web project manifest & configs
web/package.json, web/vite.config.ts, web/vitest.config.ts, web/tsconfig.json, web/tsconfig.node.json, web/.env.example
New web workspace manifests and tool configs (npm scripts, Vite dev proxy, Vitest, TypeScript configs) and example env for VITE_API_BASE_URL.
Linting
web/eslint.config.js
New ESLint configuration for Vue + TypeScript with security plugin and per-file parser overrides; excludes dist/.
SPA entry & bootstrap
web/index.html, web/src/main.ts, web/src/App.vue, web/env.d.ts
Replaces static status page with SPA entry; adds app bootstrap, global error handlers, App component, and SFC / ImportMeta typings.
Routing & guards
web/src/router/index.ts, web/src/router/guards.ts
Router with lazy routes plus an authGuard that enforces auth and redirects unauthenticated users to /login (preserving redirect query).
API client, types & endpoints
web/src/api/client.ts, web/src/api/types.ts, web/src/api/endpoints/*
Axios apiClient with JWT injection and 401 handling, unwrap helpers, a large set of TypeScript DTOs, and endpoint helpers (agents, approvals, auth, budget, company, health, messages, providers, tasks, analytics).
Pinia stores
web/src/stores/*
New stores for auth, websocket, agents, approvals, tasks, messages, budget, analytics, company, providers — with state, actions, WS handlers, loading/error handling.
Composables & utilities
web/src/composables/*, web/src/utils/*, web/src/primevue-preset.ts, web/src/styles/theme.ts, web/src/utils/constants.ts
New composables (useAuth, usePolling, useOptimisticUpdate) and utilities for formatting, errors, constants, theme tokens, and PrimeVue options.
Components, layout & views
web/src/components/..., web/src/views/*
Many new Vue SFCs (EmptyState, ErrorBoundary, LoadingSkeleton, PageHeader, StatusBadge, AppShell, Sidebar, Topbar, ConnectionStatus, LoginPage, SetupPage, PlaceholderHome, etc.).
Styles
web/src/styles/global.css, web/src/styles/theme.ts, removed web/style.css
Adds Tailwind/PrimeVue theme tokens, global CSS and scrollbar styles; removes legacy web/style.css.
Tests
web/src/__tests__/**/*.test.ts
Extensive Vitest test suites covering API client, components, composables, router guards, websocket logic, and all Pinia stores.
Docs & README
README.md, CLAUDE.md, docs/...
Documentation updated to describe the web dashboard, dev commands, roadmap/status, and usage; README/CLAUDE.md updated accordingly.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Browser as Browser/SPA
    participant Router as Vue Router
    participant AuthGuard as Auth Guard
    participant AuthStore as Auth Store
    participant APIClient as API Client
    participant Backend as Backend API

    User->>Browser: Navigate to protected route
    Browser->>Router: Route change
    Router->>AuthGuard: beforeEach
    AuthGuard->>AuthStore: check isAuthenticated
    alt not authenticated
        AuthStore-->>AuthGuard: false
        AuthGuard->>Browser: redirect /login?redirect=...
        Browser->>User: show login
        User->>Browser: submit credentials
        Browser->>AuthStore: login()
        AuthStore->>APIClient: POST /auth/login
        APIClient->>Backend: auth request
        Backend-->>APIClient: TokenResponse
        APIClient-->>AuthStore: token saved
        AuthStore->>APIClient: GET /auth/me (with JWT)
        APIClient->>Backend: request with JWT
        Backend-->>APIClient: UserInfo
        APIClient-->>AuthStore: store user
        AuthStore-->>Browser: navigate to original route
    else authenticated
        AuthStore-->>AuthGuard: true
        AuthGuard->>Router: allow
        Router->>Browser: load route
    end
Loading
sequenceDiagram
    participant Component as UI Component
    participant Store as Task Store
    participant APIClient as API Client
    participant Backend as Backend API
    participant WS as WebSocket Store

    Component->>Store: fetchTasks(filters)
    Store->>APIClient: listTasks(filters)
    APIClient->>Backend: GET /tasks
    Backend-->>APIClient: PaginatedResponse
    APIClient-->>Store: unwrapped tasks
    Store->>Component: reactive update (tasks)

    Note over WS,Store: Real‑time updates via WebSocket
    Backend->>WS: send task.created event
    WS->>Store: handleWsEvent(event)
    alt matches active filters
        Store->>Store: append task, increment total
        Store->>Component: reactive update
    else filtered out
        Store->>Store: ignore (await next fetch)
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~150 minutes

Possibly related PRs

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/web-dashboard
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/web-dashboard
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 13, 2026

Dependency Review

The following issues were found:

  • ✅ 0 vulnerable package(s)
  • ✅ 0 package(s) with incompatible licenses
  • ✅ 0 package(s) with invalid SPDX license definitions
  • ⚠️ 2 package(s) with unknown licenses.
  • ⚠️ 51 packages with OpenSSF Scorecard issues.

View full job summary

@Aureliolo Aureliolo temporarily deployed to cloudflare-preview March 13, 2026 08:12 — with GitHub Actions Inactive
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR establishes the complete Vue 3 + PrimeVue + Tailwind CSS dashboard infrastructure for SynthOrg, including the Axios API layer, 10 Pinia stores, composables, Vue Router with auth guards, TypeScript types, layout/common components, auth views, utilities, and Docker/CI integration. It is a well-structured, thoroughly reviewed foundation with 145 unit tests. Several correctness issues were found across the new code that are worth addressing before PR 2 builds on top of this foundation.

Issues found:

  • unwrap rejects valid null payloads (web/src/api/client.ts): The null/undefined data check runs even when success: true, meaning any endpoint that legitimately responds with data: null will throw an error. This will silently break DELETE-style endpoints.
  • redirect query param is never consumed (LoginPage.vue, SetupPage.vue): The auth guard correctly stores the intended destination in ?redirect=, but both auth views unconditionally push to '/' after login/setup, discarding the deep-link destination.
  • Lockout counter fires on network/5xx errors (LoginPage.vue, SetupPage.vue): attempts is incremented on every thrown error, so a user on a flaky connection can be locked out after five timeouts with no wrong password entered. Only 401s should count.
  • hasActiveFilters misses falsy non-null filter values (web/src/stores/tasks.ts): Boolean false and numeric 0 are valid API filter parameters but pass the v !== undefined && v !== null check as truthy yet are still falsy, causing the WS task-append path to fire when a filtered view is active.
  • Backend data.error short-circuits canned messages for enumerated 4xx codes (web/src/utils/errors.ts): The early-return on data?.error for status < 500 fires before the switch block, meaning 400/401/403/404/422/429 responses with a data.error body bypass the intended user-friendly messages and expose raw backend strings.

Confidence Score: 3/5

  • Safe to merge as a foundation PR but contains correctness issues in the API client and auth flow that will affect PR 2 if not fixed first.
  • The infrastructure is well-architected with good test coverage and multiple prior review rounds addressing security and reliability concerns. However, the unwrap null-data bug is a functional regression that will silently break real endpoints, the missing redirect-param consumption degrades UX for deep links, and the lockout-on-network-error issue can unexpectedly block legitimate users. These are in core shared utilities that PR 2 will depend on heavily, making them worth fixing before the pages land.
  • web/src/api/client.ts (unwrap logic), web/src/views/LoginPage.vue and web/src/views/SetupPage.vue (redirect param + lockout), web/src/utils/errors.ts (4xx message ordering), web/src/stores/tasks.ts (hasActiveFilters)

Important Files Changed

Filename Overview
web/src/api/client.ts Axios client with JWT interceptor and response envelope unwrapping; unwrap incorrectly rejects success: true responses with data: null, breaking any endpoint that intentionally returns a null payload.
web/src/stores/auth.ts Pinia auth store with JWT lifecycle, expiry timers, and HMR cleanup; double-clearAuth / double router.push possible in performAuthFlow under a 401 race, but impact is limited to duplicate history entries.
web/src/stores/websocket.ts WebSocket store with reconnect backoff, channel subscriptions, and event dispatch; reconnectAttempts and channelHandlers are now both reset in disconnect() — previously flagged issues addressed.
web/src/stores/tasks.ts Task store with full CRUD, WS event handling, and per-status grouping; hasActiveFilters misses boolean false and numeric 0 filter values, causing filtered-view contamination via WS-appended tasks.
web/src/utils/errors.ts Error utility with Axios error detection and user-friendly messages; backend data.error string is returned early for all 4xx codes including the enumerated ones (400/401/403/404/422/429), bypassing the intended canned messages.
web/src/views/LoginPage.vue Login view with client-side rate limiting; ignores the redirect query param after successful login and increments lockout counter on network/5xx errors, not just wrong-credential 401s.
web/src/views/SetupPage.vue Setup view with the same redirect and lockout-on-error issues as LoginPage; otherwise clean password confirmation and minimum-length validation.
.github/workflows/ci.yml Five new dashboard CI jobs (lint, type-check, test, build, audit) added with pinned action SHAs and persist-credentials: false; all gated to ci-pass.
docker/web/Dockerfile Multi-stage Dockerfile with non-root Node builder and pinned nginx-unprivileged runtime; build artifacts copied cleanly via --from=builder.
web/src/router/guards.ts Auth guard using meta.requiresAuth pattern with correct redirect-away-from-login for authenticated users; redirect query param is set correctly in guard but not consumed by LoginPage/SetupPage.
web/src/components/layout/ConnectionStatus.vue Previously flagged setInterval replaced with usePolling composable; health check now uses setTimeout-chained scheduling correctly.

Sequence Diagram

sequenceDiagram
    participant User
    participant LoginPage
    participant AuthStore
    participant ApiClient
    participant Backend
    participant WebSocketStore
    participant Router

    User->>LoginPage: submit credentials
    LoginPage->>AuthStore: login(username, password)
    AuthStore->>ApiClient: POST /auth/login
    ApiClient->>Backend: HTTP request
    Backend-->>ApiClient: jwt + expires_in
    ApiClient-->>AuthStore: response
    AuthStore->>AuthStore: setToken(jwt, expires_in)
    Note over AuthStore: stores in localStorage<br/>schedules expiry timer
    AuthStore->>ApiClient: GET /auth/me
    ApiClient->>Backend: Bearer jwt
    Backend-->>ApiClient: UserInfoResponse
    ApiClient-->>AuthStore: user profile
    AuthStore-->>LoginPage: success
    LoginPage->>Router: push('/') ⚠️ ignores redirect param
    Router->>WebSocketStore: connect(jwt)
    Note over WebSocketStore: JWT passed as URL query param<br/>visible in proxy/server logs
    WebSocketStore->>Backend: WebSocket upgrade
    Backend-->>WebSocketStore: connected
    WebSocketStore->>WebSocketStore: replay activeSubscriptions

    Note over ApiClient,Router: On any 401 response
    ApiClient->>ApiClient: clear localStorage
    ApiClient->>Router: dynamic import push /login
    AuthStore->>Router: clearAuth push /login
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: web/src/views/LoginPage.vue
Line: 55-60

Comment:
**Login does not navigate after redirect param**

After a successful login, the code unconditionally pushes to `'/'`. However, the auth guard preserves the intended destination in the `redirect` query parameter (e.g., `?redirect=%2Ftasks%2F123`). If a user is redirected to `/login` because they followed a deep link while unauthenticated, they will always land on the home page instead of their original destination.

`SetupPage.vue` (line 60) has the same issue.

```suggestion
    const redirect = router.currentRoute.value.query.redirect
    if (typeof redirect === 'string' && redirect.startsWith('/')) {
      router.push(redirect)
    } else {
      router.push('/')
    }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/views/LoginPage.vue
Line: 43-57

Comment:
**Lockout counter increments on network/5xx errors**

`attempts` is incremented for every thrown error, including transient network failures (`ERR_NETWORK`) and 503 responses. A user on a flaky connection can be locked out after five timeouts even without a single wrong password. Only 401 responses from the server should count towards the lockout counter.

```suggestion
  } catch (err) {
    const status = (err as { response?: { status?: number } }).response?.status
    if (status === 401) {
      attempts.value++
      if (attempts.value >= LOGIN_MAX_ATTEMPTS) {
        lockedUntil.value = Date.now() + LOGIN_LOCKOUT_MS
        attempts.value = 0
        error.value = `Too many failed attempts. Please wait ${LOGIN_LOCKOUT_MS / 1000} seconds.`
      } else {
        error.value = getErrorMessage(err)
      }
    } else {
      error.value = getErrorMessage(err)
    }
  }
```

The same pattern exists in `SetupPage.vue` around line 57.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/stores/tasks.ts
Line: 49-55

Comment:
**`hasActiveFilters` treats `false` and `0` as inactive**

The check `v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '')` will treat boolean `false` and numeric `0` as valid, non-empty filter values — but it misses them when they evaluate to falsy in a stricter test. More importantly, the comment on the WS handler says "only append if no active filters" to keep filtered views accurate, yet `false`/`0` filter values will not be caught here and tasks will be appended to a filtered view.

A simpler and more robust check:

```suggestion
  function hasActiveFilters(): boolean {
    return Object.values(currentFilters.value).some(
      (v) => v !== undefined && v !== null && v !== '' && !(typeof v === 'string' && v.trim() === ''),
    )
  }
```

Or even simpler, treat any filter value that is not `undefined`/`null`/empty-string as "active", including `false` and `0` since those are meaningful API filter parameters.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/api/client.ts
Line: 56-62

Comment:
**`unwrap` rejects a `success: true` response with `data: null`**

The condition `body.data === null || body.data === undefined` will throw even when the server intentionally returns `{ success: true, data: null }` — a valid API contract for endpoints that return no payload (e.g., a DELETE that returns 200 with `data: null`). This would cause every such endpoint to throw `"Unknown API error"` despite the call succeeding.

Consider separating the error-success check from the null-data check:

```suggestion
export function unwrap<T>(response: AxiosResponse<ApiResponse<T>>): T {
  const body = response.data
  if (!body.success) {
    throw new Error(body.error ?? 'Unknown API error')
  }
  return body.data as T
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/utils/errors.ts
Line: 29-35

Comment:
**4xx backend message leaked when status is exactly 400/401/403/404/422/429**

The function surfaces `data.error` for any 4xx response where `status < 500`. However, the `switch` block below also handles those exact statuses with more user-friendly messages. Because the `data.error` branch returns early (line 29-31) before reaching the `switch`, the backend's raw validation string is returned for 400, 401, 403, 404, 422, and 429 — which may contain internal field names or stack traces the user shouldn't see. The intent seems to be to show the backend message only for "other" 4xx codes (e.g., 402, 418) while using canned messages for the enumerated ones.

```suggestion
    if (data?.error && typeof data.error === 'string' && status !== undefined && status < 500) {
      // Only surface backend message for non-enumerated 4xx codes
      const enumeratedStatuses = new Set([400, 401, 403, 404, 409, 422, 429])
      if (!enumeratedStatuses.has(status)) {
        return data.error
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/components/layout/Topbar.vue
Line: 62-65

Comment:
**Username button sends an empty label before user is loaded**

`auth.user?.username ?? ''` renders an empty string while the user profile is being fetched (e.g., on first page load or after a hard refresh). The PrimeVue `Button` with an empty `label` prop renders as an icon-only button that is still interactive, but it provides no user-visible indicator that the user session is active.

A small improvement to display a placeholder:

```suggestion
        :label="auth.user?.username ?? '…'"
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: web/src/stores/auth.ts
Line: 86-91

Comment:
**`performAuthFlow` can enter a state where `user.value` is truthy but token is cleared**

Inside the double-check block (`if (!user.value)`), `clearAuth()` nulls both `token` and `user`, then `throw` propagates to the caller. The caller in `login` or `setup` will see an exception but `isAuthenticated` will be `false` — that part is correct. However, there is a subtle race: `fetchUser()` may have successfully set `user.value` before a 401 inside `fetchUser` cleared auth (via `clearAuth()` in `fetchUser`'s catch block). After that `clearAuth()`, `user.value` is `null`, so the outer `if (!user.value)` fires and calls `clearAuth()` a second time, which also calls `router.push('/login')` a second time. This double-push is benign in most cases but can leave duplicate history entries.

Consider checking `isAuthenticated.value` rather than `user.value` for the guard, since `clearAuth` is the authoritative reset:

```suggestion
      if (!isAuthenticated.value) {
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: bee3786

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR lays down the core infrastructure for the new web/ Vue 3 dashboard (PrimeVue + Tailwind), including API client plumbing, Pinia stores (with WebSocket support), shared UI components/composables, and CI/Docker/docs updates to build and validate the frontend.

Changes:

  • Added a TypeScript API client layer (Axios + envelope unwrapping) and endpoint modules with shared DTO-aligned types.
  • Implemented foundational Pinia stores, composables, router/auth guard, and common/layout components for the dashboard shell.
  • Integrated frontend build/lint/test/audit into CI, plus Docker multi-stage build + nginx SPA/API/WS proxy and docs updates.

Reviewed changes

Copilot reviewed 83 out of 87 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
web/vitest.config.ts Vitest + coverage configuration for the dashboard
web/vite.config.ts Vite build/dev config with /api proxy and alias
web/tsconfig.node.json Node-side TS config for tooling configs
web/tsconfig.json Strict TS config + path aliases for app source
web/style.css Removed legacy placeholder styles
web/src/views/SetupPage.vue Initial admin setup view
web/src/views/LoginPage.vue Login view + lockout UI behavior
web/src/utils/format.ts Date/number/currency formatting helpers
web/src/utils/errors.ts Axios-aware error message normalization
web/src/utils/constants.ts Shared UI and domain constants (nav, WS, auth, etc.)
web/src/styles/theme.ts Theme tokens and badge color mappings
web/src/styles/global.css Tailwind v4 import + global CSS (scrollbar + theme vars)
web/src/stores/websocket.ts WebSocket connection, reconnect, subscriptions, dispatch
web/src/stores/tasks.ts Task list CRUD + WS event handling
web/src/stores/providers.ts Provider config fetch + client-side sanitization
web/src/stores/messages.ts Message/channel fetch + WS append behavior
web/src/stores/company.ts Company config + departments fetch
web/src/stores/budget.ts Budget config/records fetch + WS record handling
web/src/stores/auth.ts JWT lifecycle, profile fetch, expiry timer, redirects
web/src/stores/approvals.ts Approval list/actions + WS updates
web/src/stores/analytics.ts Overview metrics fetch with stale-response protection
web/src/stores/agents.ts Agent list/detail fetch + WS updates
web/src/router/index.ts Minimal router skeleton + placeholder home
web/src/router/guards.ts Auth guard with public-route exceptions + redirect param
web/src/primevue-preset.ts PrimeVue Aura preset configuration
web/src/main.ts App bootstrap + global error handlers
web/src/composables/usePolling.ts Non-overlapping polling helper with cleanup
web/src/composables/useOptimisticUpdate.ts Optimistic UI helper with rollback + error capture
web/src/composables/useAuth.ts Auth-derived computed helpers for components
web/src/components/layout/Topbar.vue Topbar with user menu + WS disconnect on logout
web/src/components/layout/Sidebar.vue Sidebar nav rendering + active route logic
web/src/components/layout/ConnectionStatus.vue API health polling + WS connected indicator
web/src/components/layout/AppShell.vue Layout shell wiring sidebar/topbar/slot
web/src/components/common/StatusBadge.vue Badge rendering for status/priority/risk
web/src/components/common/PageHeader.vue Simple title/subtitle + actions slot header
web/src/components/common/LoadingSkeleton.vue PrimeVue Skeleton-based loading placeholder
web/src/components/common/ErrorBoundary.vue Error display + retry emitter
web/src/components/common/EmptyState.vue Empty state component with optional icon/action slot
web/src/api/types.ts Frontend DTO/envelope types mirroring backend
web/src/api/endpoints/tasks.ts Tasks REST endpoints
web/src/api/endpoints/providers.ts Providers REST endpoints
web/src/api/endpoints/messages.ts Messages/channels REST endpoints
web/src/api/endpoints/health.ts Health endpoint wrapper
web/src/api/endpoints/company.ts Company/departments REST endpoints
web/src/api/endpoints/budget.ts Budget endpoints
web/src/api/endpoints/auth.ts Auth endpoints
web/src/api/endpoints/approvals.ts Approval endpoints
web/src/api/endpoints/analytics.ts Analytics endpoints
web/src/api/endpoints/agents.ts Agent + autonomy endpoints
web/src/api/client.ts Axios client, JWT interceptor, 401 handling, unwrap helpers
web/src/tests/utils/format.test.ts Unit tests for formatting helpers
web/src/tests/utils/errors.test.ts Unit tests for error helpers
web/src/tests/utils/constants.test.ts Unit tests for constants/transitions/nav
web/src/tests/stores/websocket.test.ts WebSocket store behavior tests
web/src/tests/stores/tasks.test.ts Task store tests
web/src/tests/stores/messages.test.ts Message store tests
web/src/tests/stores/budget.test.ts Budget store tests
web/src/tests/stores/auth.test.ts Auth store tests
web/src/tests/stores/approvals.test.ts Approval store tests
web/src/tests/stores/agents.test.ts Agent store tests
web/src/tests/router/guards.test.ts Router guard tests
web/src/tests/composables/usePolling.test.ts Polling composable tests
web/src/tests/composables/useOptimisticUpdate.test.ts Optimistic update composable tests
web/src/tests/components/StatusBadge.test.ts StatusBadge component tests
web/src/tests/components/PageHeader.test.ts PageHeader component tests
web/src/tests/components/EmptyState.test.ts EmptyState component tests
web/src/tests/api/client.test.ts unwrap/unwrapPaginated tests
web/src/App.vue Root app shell with Toast/ConfirmDialog + RouterView
web/public/favicon.svg Dashboard favicon
web/package.json Frontend dependencies and scripts
web/nginx.conf SPA routing + API/WS proxy + security headers
web/index.html Vite entrypoint + dark theme + metadata
web/eslint.config.js ESLint flat config (Vue + security plugin)
web/env.d.ts Vite env typing + Vue module shim
web/app.js Removed legacy placeholder JS health check
web/.env.example Example env for API base URL
docs/user_guide.md Updated dashboard availability messaging
docs/roadmap/index.md Roadmap updated for dashboard “In Progress”
docs/getting_started.md Added dashboard development commands
docs/design/operations.md Updated docs to reflect Web UI in progress
docker/web/Dockerfile Multi-stage build (Node builder → nginx runtime)
README.md Updated project status to mention dashboard foundation
CLAUDE.md Documented web/ layout and dev commands
.gitignore Ignoring dashboard coverage/build artifacts
.github/workflows/ci.yml Added dashboard lint/test/build/audit gating CI
.dockerignore Ignoring web build artifacts in docker builds

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +152 to +163
function subscribe(channels: WsChannel[], filters?: Record<string, string>) {
// Track as active subscription for auto-re-subscribe on reconnect
const key = subscriptionKey(channels, filters)
if (!activeSubscriptions.some((s) => subscriptionKey(s.channels, s.filters) === key)) {
activeSubscriptions.push({ channels, filters })
}

if (!socket || socket.readyState !== WebSocket.OPEN) {
// Queue for replay when connection opens, with deduplication
if (!pendingSubscriptions.some((s) => subscriptionKey(s.channels, s.filters) === key)) {
pendingSubscriptions.push({ channels, filters })
}
web/nginx.conf Outdated
Comment on lines +23 to +25
# connect-src uses scheme-only ws:/wss: scoped to same origin via proxy — browser WS
# connections go through nginx to the backend, never to external hosts.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'" always;
RUN addgroup -S build && adduser -S build -G build
WORKDIR /app
COPY --chown=build:build web/package.json web/package-lock.json ./
RUN npm ci
Comment on lines +104 to +108
/** Discriminated paginated response envelope. */
export type PaginatedResponse<T> =
| { data: T[]; error: null; success: true; pagination: PaginationMeta }
| { data: null; error: string; success: false; pagination: null }

Comment on lines +10 to +16
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes the core infrastructure for a new web dashboard, built with Vue 3, PrimeVue, and Tailwind CSS. It provides the essential framework for API communication, state management, routing, and foundational UI components, paving the way for the development of detailed page views and features in subsequent updates. The changes significantly enhance the project's frontend capabilities and developer experience.

Highlights

  • Web Dashboard Core Infrastructure: Introduced the foundational elements for a new Vue 3, PrimeVue, and Tailwind CSS web dashboard, setting up the environment for future UI development.
  • API Layer & State Management: Implemented a robust API layer using Axios with JWT interception and response unwrapping, alongside 10 Pinia stores for comprehensive state management, including WebSocket real-time event handling.
  • Routing & Authentication: Configured Vue Router with authentication guards, lazy-loaded routes, and dedicated login/setup views to secure and manage user access.
  • Development Tooling & CI/CD: Integrated modern frontend tooling including Vite, TypeScript (strict), ESLint, and Vitest, and updated Docker configurations for multi-stage builds and CI pipelines for automated build and audit checks.
  • Comprehensive Testing: Added 145 unit tests across 17 test files, covering API client, components, composables, router guards, and Pinia stores, ensuring reliability and maintainability.
Changelog
  • .dockerignore
    • Added web dashboard build artifacts to the ignore list.
  • .gitignore
    • Included web dashboard coverage and build artifacts in the ignore list.
  • CLAUDE.md
    • Updated project layout to include the new web directory.
    • Added new CLI commands for web dashboard development.
    • Updated Docker web description to reflect Vue 3 SPA with PrimeVue and Tailwind CSS.
    • Expanded the directory structure description to include the new web/ directory and its sub-components.
  • README.md
    • Updated project status to reflect the web dashboard foundation as merged.
  • docker/web/Dockerfile
    • Converted to a multi-stage Dockerfile for building and serving the Vue dashboard.
    • Added a Node.js builder stage for frontend assets.
    • Updated Nginx stage to serve built assets from the builder stage.
  • docs/design/operations.md
    • Updated Web UI status from 'Planned' to 'In Progress'.
    • Specified Vue 3, PrimeVue, and Tailwind CSS as technologies for the Web UI.
  • docs/getting_started.md
    • Updated description of the web/ directory to reflect it as the Vue 3 dashboard.
    • Added a new section for 'Web Dashboard Development' with npm commands.
  • docs/roadmap/index.md
    • Moved 'Web dashboard' from 'Remaining Work' to 'In Progress' section.
    • Updated the description for the web dashboard in the roadmap.
  • docs/user_guide.md
    • Updated the description of the web dashboard's availability and template configuration.
  • web/.env.example
    • Added an example environment variable for the API base URL.
  • web/app.js
    • Removed the old placeholder JavaScript file.
  • web/env.d.ts
    • Added TypeScript declaration file for Vite client and Vue modules.
  • web/eslint.config.js
    • Added ESLint configuration for Vue, TypeScript, and security plugins.
  • web/index.html
    • Replaced the placeholder HTML with a new Vue 3 root template.
    • Updated meta tags and linked to the main TypeScript entry point.
  • web/nginx.conf
    • Updated Content-Security-Policy to allow inline styles for PrimeVue.
    • Added Strict-Transport-Security header.
    • Reordered WebSocket and API proxy locations to prioritize the specific WebSocket path.
  • web/package.json
    • Added a new package.json file for the web dashboard.
    • Defined dependencies including Vue, PrimeVue, Tailwind CSS, Pinia, Axios, and testing utilities.
    • Included scripts for development, build, linting, type-checking, and testing.
  • web/public/favicon.svg
    • Added a new SVG favicon for the dashboard.
  • web/src/App.vue
    • Added the root Vue component, integrating Vue Router, PrimeVue Toast, and ConfirmDialog.
  • web/src/tests/api/client.test.ts
    • Added unit tests for API client's response unwrapping functions.
  • web/src/tests/components/EmptyState.test.ts
    • Added unit tests for the EmptyState component.
  • web/src/tests/components/PageHeader.test.ts
    • Added unit tests for the PageHeader component.
  • web/src/tests/components/StatusBadge.test.ts
    • Added unit tests for the StatusBadge component.
  • web/src/tests/composables/useOptimisticUpdate.test.ts
    • Added unit tests for the useOptimisticUpdate composable.
  • web/src/tests/composables/usePolling.test.ts
    • Added unit tests for the usePolling composable.
  • web/src/tests/router/guards.test.ts
    • Added unit tests for the authentication router guard.
  • web/src/tests/stores/agents.test.ts
    • Added unit tests for the agents Pinia store, including WebSocket event handling.
  • web/src/tests/stores/approvals.test.ts
    • Added unit tests for the approvals Pinia store, covering API interactions and WebSocket events.
  • web/src/tests/stores/auth.test.ts
    • Added unit tests for the authentication Pinia store, covering login, setup, token management, and user fetching.
  • web/src/tests/stores/budget.test.ts
    • Added unit tests for the budget Pinia store, focusing on WebSocket event handling.
  • web/src/tests/stores/messages.test.ts
    • Added unit tests for the messages Pinia store, including WebSocket event handling and channel management.
  • web/src/tests/stores/tasks.test.ts
    • Added unit tests for the tasks Pinia store, covering API calls and WebSocket event handling.
  • web/src/tests/stores/websocket.test.ts
    • Added comprehensive unit tests for the WebSocket Pinia store, covering connection, reconnection, subscriptions, and event dispatching.
  • web/src/tests/utils/constants.test.ts
    • Added unit tests for application-wide constants.
  • web/src/tests/utils/errors.test.ts
    • Added unit tests for error handling utilities.
  • web/src/tests/utils/format.test.ts
    • Added unit tests for various formatting utilities.
  • web/src/api/client.ts
    • Added Axios client with JWT interceptor and response unwrapping logic.
  • web/src/api/endpoints/agents.ts
    • Added API endpoint functions for managing agents.
  • web/src/api/endpoints/analytics.ts
    • Added API endpoint function for fetching overview metrics.
  • web/src/api/endpoints/approvals.ts
    • Added API endpoint functions for managing approvals.
  • web/src/api/endpoints/auth.ts
    • Added API endpoint functions for user authentication and management.
  • web/src/api/endpoints/budget.ts
    • Added API endpoint functions for budget configuration and cost records.
  • web/src/api/endpoints/company.ts
    • Added API endpoint functions for company configuration and departments.
  • web/src/api/endpoints/health.ts
    • Added API endpoint function for checking system health.
  • web/src/api/endpoints/messages.ts
    • Added API endpoint functions for messages and channels.
  • web/src/api/endpoints/providers.ts
    • Added API endpoint functions for managing providers and their models.
  • web/src/api/endpoints/tasks.ts
    • Added API endpoint functions for managing tasks.
  • web/src/api/types.ts
    • Added comprehensive TypeScript type definitions mirroring backend Pydantic models.
  • web/src/components/common/EmptyState.vue
    • Added a reusable Vue component for displaying empty states.
  • web/src/components/common/ErrorBoundary.vue
    • Added a reusable Vue component for displaying errors with a retry option.
  • web/src/components/common/LoadingSkeleton.vue
    • Added a reusable Vue component for displaying loading skeletons.
  • web/src/components/common/PageHeader.vue
    • Added a reusable Vue component for page headers.
  • web/src/components/common/StatusBadge.vue
    • Added a reusable Vue component for displaying status badges.
  • web/src/components/layout/AppShell.vue
    • Added the main application shell layout component.
  • web/src/components/layout/ConnectionStatus.vue
    • Added a component to display API and WebSocket connection status.
  • web/src/components/layout/Sidebar.vue
    • Added the sidebar navigation component with collapse functionality.
  • web/src/components/layout/Topbar.vue
    • Added the topbar component with user menu and connection status display.
  • web/src/composables/useAuth.ts
    • Added a Vue composable for accessing authentication state and permissions.
  • web/src/composables/useOptimisticUpdate.ts
    • Added a Vue composable for optimistic UI updates with rollback on failure.
  • web/src/composables/usePolling.ts
    • Added a Vue composable for polling functions at a fixed interval.
  • web/src/main.ts
    • Configured the Vue application with Pinia, Vue Router, PrimeVue, and global error handling.
  • web/src/primevue-preset.ts
    • Defined PrimeVue theme configuration using the Aura preset and dark mode.
  • web/src/router/guards.ts
    • Implemented an authentication navigation guard for Vue Router.
  • web/src/router/index.ts
    • Configured Vue Router with initial routes for login, setup, and a placeholder home page.
  • web/src/stores/agents.ts
    • Implemented a Pinia store for managing agent data and handling WebSocket events.
  • web/src/stores/analytics.ts
    • Implemented a Pinia store for fetching and managing analytics metrics.
  • web/src/stores/approvals.ts
    • Implemented a Pinia store for managing approval items and handling WebSocket events.
  • web/src/stores/auth.ts
    • Implemented a Pinia store for authentication, managing JWT tokens, user info, and session expiry.
  • web/src/stores/budget.ts
    • Implemented a Pinia store for managing budget configuration and cost records, including WebSocket event handling.
  • web/src/stores/company.ts
    • Implemented a Pinia store for managing company configuration and departments.
  • web/src/stores/messages.ts
    • Implemented a Pinia store for managing messages and channels, including WebSocket event handling.
  • web/src/stores/providers.ts
    • Implemented a Pinia store for managing provider configurations, including sensitive data sanitization.
  • web/src/stores/tasks.ts
    • Implemented a Pinia store for managing tasks and handling WebSocket events.
  • web/src/stores/websocket.ts
    • Implemented a Pinia store for WebSocket connection management, including reconnection logic and event dispatching.
  • web/src/styles/global.css
    • Added global CSS styles, including Tailwind CSS imports, custom theme variables, and scrollbar styling.
  • web/src/styles/theme.ts
    • Defined theme color tokens and mappings for status, priority, and risk badges.
  • web/src/utils/constants.ts
    • Defined application-wide constants for API limits, password requirements, task statuses, and navigation items.
  • web/src/utils/errors.ts
    • Implemented error handling utilities for user-friendly messages and Axios error detection.
  • web/src/utils/format.ts
    • Implemented formatting utilities for dates, currency, numbers, uptime, and labels.
  • web/src/views/LoginPage.vue
    • Added the login page component with form handling and lockout logic.
  • web/src/views/SetupPage.vue
    • Added the initial setup page component for creating an admin account.
  • web/style.css
    • Removed the old placeholder CSS file.
  • web/tsconfig.json
    • Added TypeScript configuration for the web project.
  • web/tsconfig.node.json
    • Added TypeScript configuration for Node.js-related files in the web project.
  • web/vite.config.ts
    • Added Vite configuration for the web project, including Vue plugin, Tailwind CSS, path aliases, and API proxy.
  • web/vitest.config.ts
    • Added Vitest configuration for unit testing the web project.
Ignored Files
  • Ignored by pattern: .github/workflows/** (1)
    • .github/workflows/ci.yml
Activity
  • This code underwent 3 rounds of local agent review (code-reviewer, type-design-analyzer, silent-failure-hunter, pr-test-analyzer, comment-analyzer, security-reviewer).
  • It also received 2 rounds of external reviewer feedback (CodeRabbit, Copilot, Gemini, Greptile, Qodo, Ellipsis).
  • A total of 137+ findings were addressed across all review rounds.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive and well-structured foundation for the new Vue 3 web dashboard. The architecture is robust, featuring a clean API layer, well-designed Pinia stores with real-time capabilities, and a secure authentication flow. The code quality is exceptionally high, with thorough testing, good security practices like input sanitization and thoughtful error handling, and modern frontend patterns. I have a few suggestions and one critical issue regarding package versions. My feedback focuses on dependency correctness, WebSocket security, and configuration best practices.

Comment on lines +31 to +47
"devDependencies": {
"@types/node": "^25.5.0",
"@typescript-eslint/parser": "^8.57.0",
"@vitejs/plugin-vue": "^5",
"@vitest/coverage-v8": "^3.2.4",
"@vue/test-utils": "^2",
"@vue/tsconfig": "^0.7",
"eslint": "^9",
"eslint-plugin-security": "^4.0.0",
"eslint-plugin-vue": "^9",
"jsdom": "^26",
"typescript": "^5.7",
"typescript-eslint": "^8.57.0",
"vitest": "^3",
"vue-tsc": "^2"
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Several devDependencies have versions that reference non-existent major versions, which will likely cause npm install to fail. Please review and correct them to valid, existing versions.

For example:

  • @types/node: ^25.5.0 (latest is ^20.x)
  • @typescript-eslint/parser: ^8.57.0 (latest is ^7.x)
  • typescript-eslint: ^8.57.0 (latest is ^7.x)
  • jsdom: ^26 (latest is ^24.x)
  • vitest: ^3 (latest is ^1.x)

Additionally, several other dependencies point to pre-release versions (e.g., tailwindcss: ^4, primevue: ^4). While this may be intentional, it's a stability risk for a core infrastructure PR.

Comment on lines +50 to +52
// TODO: Replace with one-time WS ticket endpoint for production security.
// Currently passes JWT as query param which is logged in server/proxy/browser.
const url = `${getWsUrl()}?token=${encodeURIComponent(token)}`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The WebSocket connection URL includes the JWT as a query parameter. This is a security risk as URLs can be logged by servers, proxies, and stored in browser history, potentially exposing the token. As noted in the TODO comment, this should be addressed for production. A more secure approach is to exchange the JWT for a short-lived, single-use ticket via a separate REST endpoint, and then use that ticket to authenticate the WebSocket connection.

web/nginx.conf Outdated
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 86400s;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The proxy_read_timeout for WebSockets is set to 86400 seconds (24 hours). While WebSockets often require long timeouts to prevent premature disconnection, 24 hours is exceptionally long and could lead to resource exhaustion on the server if many connections remain open but idle. Consider using a shorter timeout (e.g., 1-2 hours) and implementing an application-level keep-alive (ping/pong) mechanism to maintain active connections while allowing truly idle ones to be cleaned up more promptly.

Comment on lines +22 to +27
{
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'warn',
},
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Disabling the vue/multi-word-component-names rule globally is convenient, but it removes a useful safeguard against potential conflicts with existing and future HTML elements. It's recommended to enforce this rule by default and only disable it for specific files where necessary, such as the root App.vue component.

  {
    rules: {
      'vue/no-v-html': 'warn',
    },
  },
  {
    files: ['src/App.vue'],
    rules: {
      'vue/multi-word-component-names': 'off',
    },
  },

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 42

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 102-121: The current dashboard-lint job bundles both lint and
type-check, which hides type-check results on lint failure; split this into two
separate jobs: keep dashboard-lint to run only "npm run lint" in the web
working-directory and create a new dashboard-typecheck job that runs only "npm
run type-check" with the same runs-on, permissions, checkout and setup-node
steps (reuse the same setup-node/node-version/cache inputs). Then update the CI
gate (ci-pass) to include both dashboard-lint and dashboard-typecheck so lint
and type-check run in parallel rather than serially.

In `@web/index.html`:
- Around line 10-12: Add minimal fallback content inside the root container div
with id "app" so the page isn't blank if client bootstrap fails: update the <div
id="app"></div> element to include a short, user-friendly placeholder (e.g. a
"Loading…" message or a diagnostic note) that will be replaced when Vue mounts;
ensure you only change the markup inside the div (the element with id "app") and
do not modify the existing script tag or body classes.

In `@web/nginx.conf`:
- Around line 22-25: Update the comment that currently claims scheme-only
ws:/wss: sources are used to instead describe the actual emitted header from the
add_header Content-Security-Policy directive (it uses connect-src 'self'), and
explicitly note the WebSocket compatibility caveat: explain that 'self'
restricts to same-origin WebSocket connections but some browsers have
inconsistent support so you may need explicit host or scheme entries (ws: /
wss:) depending on target browsers; change the comment text around the
add_header Content-Security-Policy line to match this accurate behavior and
recommendation.

In `@web/src/__tests__/api/client.test.ts`:
- Around line 15-86: Add tests that cover the axios interceptors in
web/src/api/client.ts: create a test that sets a token in localStorage, obtains
the configured axios instance (exported as apiClient or obtained via the
module's setup function), simulate a request through the request interceptor and
assert the Authorization header contains `Bearer <token>`; and create a test
that simulates a 401 response through the response interceptor (mock a response
with status 401 or call the interceptor's error handler), spy on
localStorage.removeItem/clear and on window.location.assign (or the module
function that redirects) and assert the token is cleared and redirect is
invoked. Use jest.spyOn/mocks to inject the fake token and to invoke the
interceptor functions directly or by mocking axios.request to trigger the
interceptors so the tests exercise the auth header injection and the 401
token-clear/redirect branches.

In `@web/src/__tests__/components/EmptyState.test.ts`:
- Around line 5-38: Add a test in the EmptyState.test suite that mounts the
EmptyState component with the named "action" slot provided (use the same mount
helpers as other tests) and assert the slot content is rendered and accessible;
reference the EmptyState component and the "action" slot name so you locate the
slot implementation in EmptyState.vue lines ~18-19, then in the new test mount
EmptyState with props (e.g., title) and a slot template for "action" and assert
the rendered output contains the slot content and that it appears in the
expected DOM node (e.g., find by text or selector).

In `@web/src/__tests__/components/StatusBadge.test.ts`:
- Around line 5-26: Add assertions that verify the color-class applied for each
branch so the getColorClass() mapping is exercised; for each test that mounts
StatusBadge (the 'renders status value as label', 'renders priority type', and
'renders risk type' cases) add one assertion that the root element's class list
(wrapper.classes() or wrapper.attributes('class')) contains the expected CSS
class returned by getColorClass for the given props (e.g., the class for
'in_progress' when type is default/status, for 'critical' when type is
'priority', and for 'high' when type is 'risk'), or import/use the same mapping
used by getColorClass in StatusBadge.vue to derive the expected class names and
assert they are present.

In `@web/src/__tests__/composables/usePolling.test.ts`:
- Around line 14-112: Add a new test in
web/src/__tests__/composables/usePolling.test.ts that asserts usePolling(fn,
intervalMs) validates intervalMs and rejects invalid values: confirm that
calling usePolling with 0, a negative number, NaN, or Infinity either
immediately throws (or returns an object whose start() throws) — pick the
library's current error behavior — and assert an Error is thrown (e.g., with
toThrow()). Reference the usePolling symbol in the test and cover at least
intervalMs values 0, -100, NaN, and Infinity so this validation cannot regress.

In `@web/src/__tests__/stores/budget.test.ts`:
- Around line 24-48: Add unit tests in web/src/__tests__/stores/budget.test.ts
to cover the fetch action success and error paths for useBudgetStore: write
tests that call store.fetchConfig, store.fetchRecords(params), and
store.fetchAgentSpending(agentId) while mocking the API responses (both resolved
and rejected) and assert the store mutations (config, records, totalRecords,
agentSpending) and any error state/flags; also verify that handleWsEvent
continues to work when fetch actions have failed. Locate the store via
useBudgetStore and mock the underlying API/client used by those methods to
simulate 200 and error responses, then assert expected state transitions and
error handling for each method.

In `@web/src/__tests__/stores/tasks.test.ts`:
- Around line 157-186: Add failing-path tests for transitionTask and cancelTask
similar to updateTask's error test: for transitionTask, have
mockTransitionTask.mockRejectedValue(new Error('Conflict')) then call
useTaskStore(), await store.transitionTask('task-1', { target_status: 'assigned'
}) and assert the result is null and store.error equals the error message; for
cancelTask, have mockCancelTask.mockRejectedValue(new Error('Conflict')), call
store.cancelTask('task-1', { reason: 'done' }) and assert null result and
store.error === 'Conflict'. Ensure you reference the same helpers used in the
file (useTaskStore, mockTransitionTask, mockCancelTask, and store.error) and
match the pattern of the existing success tests.
- Around line 6-20: Remove the unused mock or add a test that exercises it:
either delete the mockGetTask declaration and its mapping inside the vi.mock
block (remove mockGetTask and the getTask: (...args) => mockGetTask(...args)
entry) to avoid an unused mock, or add a unit test that calls the store method
which triggers the tasks.getTask API (e.g., the store action or getter that
calls getTask) and assert mockGetTask was called; reference mockGetTask and the
mocked getTask to locate the code to change.

In `@web/src/api/client.ts`:
- Around line 8-12: BASE_URL may include a trailing slash or already contain the
"/api/v1" path which causes double slashes or duplicated segments when used in
apiClient; normalize BASE_URL before concatenation by trimming any trailing
slashes and removing any existing "/api/v1" suffix (or build the final origin
via a URL-join utility) so that baseURL for apiClient is always a single
canonical origin plus "/api/v1" (update the code that computes BASE_URL and the
apiClient.create baseURL to use the normalized value).

In `@web/src/api/endpoints/company.ts`:
- Around line 1-16: The three endpoint functions (getCompanyConfig,
listDepartments, getDepartment) call apiClient.get without preserving the
response envelope type, so update each call to pass the proper generic response
type to apiClient.get (e.g. ApiResponse<CompanyConfig> for getCompanyConfig,
PaginatedResponse<Department> for listDepartments, ApiResponse<Department> for
getDepartment) and keep using unwrap/unwrapPaginated to extract the payload;
this means changing the apiClient.get(...) invocations to
apiClient.get<ApiResponse<...>>(...) or
apiClient.get<PaginatedResponse<...>>(...) so TypeScript enforces the response
contracts at this boundary.

In `@web/src/api/endpoints/providers.ts`:
- Around line 1-12: The listProviders and getProvider functions currently return
the unwrapped backend response (via unwrap(response)) which can contain
sensitive api_key fields; update these functions to accept the raw response type
(e.g., RawProviderConfig / Record<string, RawProviderConfig>), then create and
return a sanitized object that omits api_key before returning to callers.
Concretely: in listProviders call unwrap(response) into a RawRecord, iterate
keys to copy each provider into a new Record<string, ProviderConfig> without the
api_key property; in getProvider call unwrap(response) into a RawProviderConfig
and return a ProviderConfig built by copying fields except api_key. Keep the new
Raw* types for the unsanitized shape and ensure the exported return types of
listProviders and getProvider remain ProviderConfig (sanitized).

In `@web/src/components/layout/ConnectionStatus.vue`:
- Around line 23-26: The current onMounted handler starts checkHealth via
setInterval which can launch a new checkHealth before the previous async call
finishes (causing concurrent requests and races); change the polling to
serialize calls by replacing the setInterval approach with a guarded loop or
recursive setTimeout: track an in-flight flag or await the previous checkHealth
promise before scheduling the next poll (use the existing symbols checkHealth,
pollTimer and HEALTH_POLL_INTERVAL), ensure pollTimer is set/cleared
appropriately and that onUnmounted still clears the timer to avoid leaks.

In `@web/src/components/layout/Sidebar.vue`:
- Around line 63-69: The collapse toggle button in Sidebar.vue (the <button>
element with `@click`="$emit('toggle')" and :aria-label bound to collapsed) is
missing an explicit type, which can cause it to act as a submit button when
placed inside a form; add type="button" to that button so it does not trigger
form submission when clicked and preserve its current click handling and aria
attributes.
- Around line 44-59: Buttons rendered from NAV_ITEMS in the Sidebar template can
accidentally submit a surrounding form because they lack an explicit type;
update the <button> elements used in the v-for (the one that uses NAV_ITEMS,
isActive(item.to) and `@click`="navigate(item.to)") to include type="button" so
they behave as non-submit controls and follow accessibility/best-practice
guidelines.

In `@web/src/composables/useAuth.ts`:
- Around line 14-17: The computed `canWrite` currently reads `store.userRole`
directly; change it to use the existing `userRole` computed ref by referencing
`userRole.value` instead, so update the `canWrite` function to read
`userRole.value` and check `(WRITE_ROLES as readonly
string[]).includes(userRole.value)` to keep consistency with the established
pattern while preserving the same behavior.

In `@web/src/composables/useOptimisticUpdate.ts`:
- Around line 15-20: execute currently allows re-entrant calls because pending
is shared; add a guard at the start of execute: if (pending.value) return null
(or throw) to prevent starting a second optimistic update while one is in
flight. For a more robust fix, also assign a local opId (e.g. const opId =
Symbol()) and store it on a shared currentOpId; in finally/rollback logic only
clear pending or run the rollback if currentOpId === opId so an older completion
cannot revert a newer optimistic state; reference execute, pending,
applyOptimistic, serverAction, and the rollback/finally block when making these
changes.
- Around line 28-34: The code currently logs raw error objects; change both
console.error calls to log only sanitized messages using getErrorMessage() (or
the already-set error.value) instead of dumping err or rollbackErr.
Specifically, inside useOptimisticUpdate where rollback?.() is caught, replace
logging of rollbackErr with a sanitized message via
getErrorMessage(rollbackErr); and replace the final console.error('Optimistic
update failed:', err) with console.error('Optimistic update failed:',
getErrorMessage(err)) or console.error('Optimistic update failed:', error.value)
after you assign error.value = getErrorMessage(err).

In `@web/src/composables/usePolling.ts`:
- Line 7: The usePolling function currently accepts intervalMs unvalidated which
allows 0, negative, NaN, or Infinity to create runaway polling; add validation
at the start of usePolling: define a MIN_POLL_INTERVAL constant (e.g. 100ms),
check Number.isFinite(intervalMs) and that intervalMs >= MIN_POLL_INTERVAL, and
if not either clamp intervalMs to MIN_POLL_INTERVAL or throw a clear error;
update all callers or document the behavior and use the validated/clamped value
in the polling loop (refer to usePolling and the variable intervalMs).

In `@web/src/main.ts`:
- Around line 33-36: The unhandledrejection handler registered via
window.addEventListener('unhandledrejection', ...) logs a sanitized reason but
does not cancel the browser's default reporting; update the handler to call
event.preventDefault() inside the listener (after capturing/sanitizing
event.reason with sanitizeForLog) so the browser does not also print the
original unsanitized rejection to the console; keep using
sanitizeForLog(event.reason) for the logged message.

In `@web/src/primevue-preset.ts`:
- Around line 1-12: Update the TypeScript import for PrimeVueConfiguration so it
matches PrimeVue 4.x exports: change the import source for the type used by
primeVueOptions from 'primevue' to 'primevue/config' (ensure the symbol
PrimeVueConfiguration is imported from 'primevue/config' and used to type the
exported primeVueOptions object).

In `@web/src/router/index.ts`:
- Around line 6-11: The inline PlaceholderHome component defined via
defineComponent (name: 'PlaceholderHome', render: ...) should be extracted into
its own file to keep views consistent and make future page additions easier;
create a new file under views (e.g., views/PlaceholderHome.vue or
views/PlaceholderHome.ts), move the defineComponent implementation there, export
it as the default, then import and use that component in web/src/router/index.ts
in place of the inline PlaceholderHome reference.

In `@web/src/stores/agents.ts`:
- Around line 40-52: The handler for the 'agent.hired' event currently casts
payload to AgentConfig after only validating name, id, role, and department
which can produce incomplete AgentConfig objects; update the logic in the
'agent.hired' case to either (a) validate all required AgentConfig fields
(level, status, personality, model, skills, memory, tools, hiring_date, etc.) on
payload before pushing to agents.value and incrementing total.value, ensuring
correct types/structures for arrays/objects, or (b) if the payload is partial,
call the existing API helper (e.g., fetchAgentById or similar) using payload.id
to retrieve the full AgentConfig and then append that returned object to
agents.value and increment total.value; ensure you no longer blindly cast
payload as AgentConfig and only push a fully-populated agent.

In `@web/src/stores/approvals.ts`:
- Around line 53-67: The handler handleWsEvent currently casts a partial payload
to ApprovalItem and pushes it into approvals.value on 'approval.submitted', but
only validates id, action_type, and title; update this by either validating all
required ApprovalItem fields (description, requested_by, risk_level, status,
task_id, metadata, created_at, etc.) on payload before casting and inserting
into approvals.value and incrementing total.value, or — preferably — call the
API to fetch the full approval record by payload.id (e.g., a getApprovalById /
fetchApproval function) inside the 'approval.submitted' case and push the
returned complete ApprovalItem into approvals.value and increment total.value
only after successful fetch; ensure you guard against duplicates using the
existing approvals.value.some check and handle fetch errors without inserting
incomplete objects.

In `@web/src/stores/auth.ts`:
- Around line 140-152: The changePassword function currently omits error
handling and lets exceptions bubble up; either add a try/catch like other
methods (e.g., fetchUser) to capture errors, set/loading state and surface a
normalized error or call an error handler, or explicitly document intent with a
comment above changePassword indicating errors are intentionally propagated to
the caller; update the function changePassword to mirror the pattern used by
fetchUser (catch error, optionally set an error store or rethrow a wrapped
error) or add the explanatory comment so maintainers know this behavior is
deliberate.
- Around line 78-99: Both setup() and login() duplicate the same post-auth
sequence (setting token, calling fetchUser, handling fetch errors by calling
clearAuth, checking !user.value, and toggling loading.value); extract that into
a private helper like performAuthFlow(authFn, flowName) and have setup and login
call it. The helper should set loading.value = true/false around the call, await
authFn() to get { token, expires_in }, call setToken(result.token,
result.expires_in), await fetchUser() with a try/catch that calls clearAuth()
and throws a flow-specific error string (use flowName in the message), check if
(!user.value) to clearAuth and throw the same message, and finally return the
auth result; replace the duplicated blocks in setup and login with calls to
performAuthFlow(() => authApi.setup(...), 'Setup') and performAuthFlow(() =>
authApi.login(...), 'Login').

In `@web/src/stores/budget.ts`:
- Around line 42-50: fetchAgentSpending currently doesn't manage the loading
state like fetchConfig/fetchRecords; set loading.value = true at the start of
fetchAgentSpending, and ensure loading.value = false is set in a finally block
after awaiting budgetApi.getAgentSpending so the UI sees a consistent loading
lifecycle; keep existing error handling (error.value = null before, error.value
= getErrorMessage(err) on catch) and reference the fetchAgentSpending function,
loading.value, error.value, budgetApi.getAgentSpending and getErrorMessage when
making the change.
- Around line 52-60: The handler handleWsEvent currently unsafely casts
event.payload to CostRecord; replace the double assertion with a runtime type
guard that verifies required fields (e.g., agent_id, cost_usd as number,
timestamp as string/number) and their types before treating payload as a
CostRecord, and only then update records.value and totalRecords.value
(respecting MAX_WS_RECORDS); implement the guard as a small function
(isCostRecord(payload): payload is CostRecord) and use it inside handleWsEvent
to guard the push to records.

In `@web/src/stores/company.ts`:
- Around line 16-38: fetchConfig and fetchDepartments can suffer from race
conditions where an earlier request finishes after a later one and overwrites
loading/error state; add a per-action request guard (e.g., configRequestId and
departmentsRequestId counters or per-action AbortController) that you
increment/create before awaiting the API call, capture the current id/controller
in a local variable, and only update
config.value/configError.value/loading.value (and
departments.value/departmentsError.value/departmentsLoading.value) if the
captured id/controller still matches the latest one when the promise resolves or
rejects; ensure the finally block also checks the guard so outdated requests
cannot flip loading flags.

In `@web/src/stores/messages.ts`:
- Around line 50-61: The WebSocket handler handleWsEvent currently increments
total when appending a message, which allows total to drift from the server
paginated count; change handleWsEvent to append incoming messages to
messages.value (respecting activeChannel and MAX_WS_MESSAGES) but do not modify
total on WS events, and if you need a client-side counter keep a separate
variable (e.g., sessionMessageCount) or update documentation to clarify that
total is strictly the server-side total and is only set from the paginated API
response.

In `@web/src/stores/providers.ts`:
- Around line 8-14: sanitizeProviders currently writes dynamic keys into a plain
object which allows prototype-pollution if a key like "__proto__" arrives;
change the accumulator to a dictionary with no prototype (use
Object.create(null)) and skip/deny unsafe property names (e.g., "__proto__",
"prototype", "constructor") when iterating Object.entries. In function
sanitizeProviders, create result via Object.create(null), check each key against
a small denylist before assigning, and continue to the next entry if matched;
still strip api_key as before and assign safe into result, returning the
prototype-free record.

In `@web/src/stores/tasks.ts`:
- Around line 57-68: The createTask function can double-count total when a
task-created WS event races with the API response; update createTask to check
whether tasks.value already contains the task id before appending and
incrementing total: after receiving task from tasksApi.createTask, if
tasks.value.some(t => t.id === task.id) then just return task (do not push or
increment total), otherwise push and increment; this keeps behavior consistent
with handleWsEvent and avoids the race window without changing WS logic.
- Around line 35-40: The hasActiveFilters function currently treats empty
strings as active because it only checks for undefined/null; update the
predicate used on Object.values(currentFilters.value) to also treat empty
strings (and ideally whitespace-only strings) as inactive. Specifically, in
hasActiveFilters replace the test (v !== undefined && v !== null) with a check
that additionally excludes string values that are empty or only whitespace
(e.g., ensure v is not '' or (typeof v === 'string' && v.trim() === '') ), so
currentFilters entries like { status: '' } are considered inactive.

In `@web/src/stores/websocket.ts`:
- Around line 176-196: The unsubscribe function currently removes tracked
subscriptions only when all of a subscription's channels are present in the
provided channels array (using channels.every), which prevents partial
unsubscriptions (e.g., unsubscribing "tasks" won't remove a subscription
["tasks","agents"]); change the predicate in both loops that iterate
activeSubscriptions and pendingSubscriptions to use some (e.g.,
subscription.channels.some(c => channelSet.has(c))) so any matching channel will
remove that subscription entry (and leave the socket.send logic intact), or if
exact-match behavior is intended, add a clear comment and/or adjust callers to
pass the full channel set instead; locate this change in the unsubscribe
function and update both loops using activeSubscriptions and
pendingSubscriptions.
- Around line 44-53: The connect function currently appends the JWT as a query
param to the WebSocket URL (see connect, currentToken, getWsUrl) which exposes
the token; change connect to first call a backend endpoint to exchange the JWT
for a short-lived one-time WS ticket (implement a helper like fetchWsTicket or
getWsTicket) using the existing token, then open the WebSocket to getWsUrl()
with that ticket (or no sensitive data in query) instead of the raw JWT; ensure
currentToken is kept client-side only, remove encodeURIComponent(token) usage in
the URL, handle failures from the ticket exchange (log/abort) and ensure the
ticket is single-use/short-lived on the backend so production logs no longer
contain the JWT.

In `@web/src/styles/theme.ts`:
- Around line 36-50: Replace the loose Record<string,string> with explicit union
key types: declare and export a type Status =
'created'|'assigned'|'in_progress'|'in_review'|'completed'|'blocked'|'failed'|'interrupted'|'cancelled'|'pending'|'approved'|'rejected'|'expired'
and change statusColors to Record<Status,string>; do the same pattern for the
other maps (e.g., priorityColors, riskColors) by adding appropriate union types
(Priority, Risk) and using Record<Priority,string> / Record<Risk,string> so
consumers get compile-time safety and IDE autocompletion.

In `@web/src/utils/constants.ts`:
- Around line 54-66: NAV_ITEMS is declared with as const but lacks an exported
reusable type; add and export a NavItem type so consumers can use it for
type-safe iteration or extension by deriving it from NAV_ITEMS (e.g., export
type NavItem = (typeof NAV_ITEMS)[number]) and update any consumers to import
NavItem where needed; ensure the new type is exported from the same module
alongside NAV_ITEMS.

In `@web/src/utils/errors.ts`:
- Around line 3-10: The custom type guard isAxiosError uses a manual property
check which can be brittle; replace its implementation to call Axios's official
helper (importing axios or the isAxiosError helper from 'axios') and return
axios.isAxiosError(error) so the function delegates to Axios internals for
accurate detection while preserving the exported function name isAxiosError.

In `@web/src/utils/format.ts`:
- Around line 27-35: The current check treats future timestamps (diffMs < 0) as
'just now'; change that to a safer fallback by returning formatDate(iso) for
negative diffs. Locate the time-diff logic using diffMs, diffSec, iso, date and
now, replace the early "if (diffMs < 0) return 'just now'" with "if (diffMs < 0)
return formatDate(iso)" so future/clock-skewed dates render as a formatted date
instead of "just now".

In `@web/src/views/LoginPage.vue`:
- Around line 21-33: The computed locked currently uses Date.now()
(non-reactive) so it won't update when the lockout expires; make the computed
depend on a reactive "now" ref that is updated on an interval so the UI
re-evaluates (e.g., create a ref now and update it with
setInterval/clearInterval on unmount), keep the existing checkAndClearLockout()
logic but ensure locked computed reads now and lockedUntil.value, and ensure the
interval is cleaned up in the component's teardown to avoid leaks so the Submit
button becomes enabled immediately when lockedUntil passes.

In `@web/src/views/SetupPage.vue`:
- Around line 20-33: The computed `locked` uses Date.now() directly so it
doesn't update reactively; change it to depend on a reactive clock (e.g., create
a `now` ref updated with setInterval or use a time composable) and use
`now.value` inside the `locked` computed so the button UI updates when the lock
expires; ensure the interval is started where these refs are defined and cleaned
up on component unmount, and leave `checkAndClearLockout`, `handleSetup`,
`lockedUntil`, `attempts`, and `error` logic unchanged except for replacing
Date.now() with the reactive `now` reference.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 44611bbc-da58-4b5c-96f1-382e22e46f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 2f10d49 and a2857ac.

⛔ Files ignored due to path filters (2)
  • web/package-lock.json is excluded by !**/package-lock.json
  • web/public/favicon.svg is excluded by !**/*.svg
📒 Files selected for processing (85)
  • .dockerignore
  • .github/workflows/ci.yml
  • .gitignore
  • CLAUDE.md
  • README.md
  • docker/web/Dockerfile
  • docs/design/operations.md
  • docs/getting_started.md
  • docs/roadmap/index.md
  • docs/user_guide.md
  • web/.env.example
  • web/app.js
  • web/env.d.ts
  • web/eslint.config.js
  • web/index.html
  • web/nginx.conf
  • web/package.json
  • web/src/App.vue
  • web/src/__tests__/api/client.test.ts
  • web/src/__tests__/components/EmptyState.test.ts
  • web/src/__tests__/components/PageHeader.test.ts
  • web/src/__tests__/components/StatusBadge.test.ts
  • web/src/__tests__/composables/useOptimisticUpdate.test.ts
  • web/src/__tests__/composables/usePolling.test.ts
  • web/src/__tests__/router/guards.test.ts
  • web/src/__tests__/stores/agents.test.ts
  • web/src/__tests__/stores/approvals.test.ts
  • web/src/__tests__/stores/auth.test.ts
  • web/src/__tests__/stores/budget.test.ts
  • web/src/__tests__/stores/messages.test.ts
  • web/src/__tests__/stores/tasks.test.ts
  • web/src/__tests__/stores/websocket.test.ts
  • web/src/__tests__/utils/constants.test.ts
  • web/src/__tests__/utils/errors.test.ts
  • web/src/__tests__/utils/format.test.ts
  • web/src/api/client.ts
  • web/src/api/endpoints/agents.ts
  • web/src/api/endpoints/analytics.ts
  • web/src/api/endpoints/approvals.ts
  • web/src/api/endpoints/auth.ts
  • web/src/api/endpoints/budget.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/health.ts
  • web/src/api/endpoints/messages.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/endpoints/tasks.ts
  • web/src/api/types.ts
  • web/src/components/common/EmptyState.vue
  • web/src/components/common/ErrorBoundary.vue
  • web/src/components/common/LoadingSkeleton.vue
  • web/src/components/common/PageHeader.vue
  • web/src/components/common/StatusBadge.vue
  • web/src/components/layout/AppShell.vue
  • web/src/components/layout/ConnectionStatus.vue
  • web/src/components/layout/Sidebar.vue
  • web/src/components/layout/Topbar.vue
  • web/src/composables/useAuth.ts
  • web/src/composables/useOptimisticUpdate.ts
  • web/src/composables/usePolling.ts
  • web/src/main.ts
  • web/src/primevue-preset.ts
  • web/src/router/guards.ts
  • web/src/router/index.ts
  • web/src/stores/agents.ts
  • web/src/stores/analytics.ts
  • web/src/stores/approvals.ts
  • web/src/stores/auth.ts
  • web/src/stores/budget.ts
  • web/src/stores/company.ts
  • web/src/stores/messages.ts
  • web/src/stores/providers.ts
  • web/src/stores/tasks.ts
  • web/src/stores/websocket.ts
  • web/src/styles/global.css
  • web/src/styles/theme.ts
  • web/src/utils/constants.ts
  • web/src/utils/errors.ts
  • web/src/utils/format.ts
  • web/src/views/LoginPage.vue
  • web/src/views/SetupPage.vue
  • web/style.css
  • web/tsconfig.json
  • web/tsconfig.node.json
  • web/vite.config.ts
  • web/vitest.config.ts
💤 Files with no reviewable changes (2)
  • web/style.css
  • web/app.js
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (3)
docs/**/*.md

📄 CodeRabbit inference engine (CLAUDE.md)

Docs source: docs/ (Markdown, built with Zensical). Design spec: docs/design/ (7 pages: index, agents, organization, communication, engine, memory, operations).

Files:

  • docs/user_guide.md
  • docs/roadmap/index.md
  • docs/getting_started.md
  • docs/design/operations.md
docker/**

📄 CodeRabbit inference engine (CLAUDE.md)

Docker: Backend 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened. Web: nginxinc/nginx-unprivileged, SPA routing, API/WebSocket proxy to backend. All Docker files in docker/ directory.

Files:

  • docker/web/Dockerfile
.github/workflows/*.yml

📄 CodeRabbit inference engine (CLAUDE.md)

CI: Jobs (lint + type-check + test in parallel) → ci-pass gate. Pages workflow exports OpenAPI schema, builds Astro landing + Zensical docs, merges, deploys to GitHub Pages on push to main.

Files:

  • .github/workflows/ci.yml
🧠 Learnings (7)
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to docs/**/*.md : Docs source: `docs/` (Markdown, built with Zensical). Design spec: `docs/design/` (7 pages: index, agents, organization, communication, engine, memory, operations).

Applied to files:

  • CLAUDE.md
  • .gitignore
  • README.md
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to docker/** : Docker: Backend 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened. Web: `nginxinc/nginx-unprivileged`, SPA routing, API/WebSocket proxy to backend. All Docker files in `docker/` directory.

Applied to files:

  • CLAUDE.md
  • .gitignore
  • .dockerignore
  • docker/web/Dockerfile
  • docs/roadmap/index.md
  • docs/getting_started.md
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to .github/workflows/docker.yml : Docker CI: `.github/workflows/docker.yml` builds backend + web images, pushes to GHCR, signs with cosign. Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass.

Applied to files:

  • CLAUDE.md
  • .dockerignore
  • docker/web/Dockerfile
  • .github/workflows/ci.yml
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to .github/workflows/*.yml : CI: Jobs (lint + type-check + test in parallel) → ci-pass gate. Pages workflow exports OpenAPI schema, builds Astro landing + Zensical docs, merges, deploys to GitHub Pages on push to main.

Applied to files:

  • CLAUDE.md
  • .github/workflows/ci.yml
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to pyproject.toml : Dependencies: all versions pinned with `==` in `pyproject.toml`. Groups: `test` and `dev` (includes test). Install via `uv sync`.

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to src/**/*.py : Linting: `uv run ruff check src/ tests/` and format with `uv run ruff format src/ tests/`

Applied to files:

  • CLAUDE.md
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to src/**/*.py : Type-checking: `uv run mypy src/ tests/` (strict mode)

Applied to files:

  • CLAUDE.md
🧬 Code graph analysis (38)
web/src/api/endpoints/analytics.ts (2)
web/src/api/types.ts (2)
  • OverviewMetrics (372-377)
  • ApiResponse (94-96)
web/src/api/client.ts (2)
  • apiClient (10-14)
  • unwrap (50-56)
web/src/composables/useAuth.ts (2)
web/src/stores/auth.ts (1)
  • useAuthStore (8-171)
web/src/utils/constants.ts (1)
  • WRITE_ROLES (52-52)
web/src/router/index.ts (1)
web/src/router/guards.ts (1)
  • authGuard (12-36)
web/src/api/endpoints/auth.ts (1)
web/src/api/client.ts (2)
  • apiClient (10-14)
  • unwrap (50-56)
web/src/api/endpoints/health.ts (2)
web/src/api/types.ts (2)
  • HealthStatus (470-476)
  • ApiResponse (94-96)
web/src/api/client.ts (2)
  • apiClient (10-14)
  • unwrap (50-56)
web/src/api/endpoints/providers.ts (1)
web/src/api/client.ts (2)
  • apiClient (10-14)
  • unwrap (50-56)
web/src/__tests__/stores/tasks.test.ts (2)
web/src/api/types.ts (2)
  • Task (152-178)
  • WsEvent (519-524)
web/src/stores/tasks.ts (1)
  • useTaskStore (15-147)
web/src/__tests__/composables/useOptimisticUpdate.test.ts (2)
web/src/composables/useOptimisticUpdate.ts (1)
  • useOptimisticUpdate (11-42)
src/ai_company/engine/parallel_models.py (1)
  • pending (287-289)
web/src/__tests__/stores/auth.test.ts (1)
web/src/stores/auth.ts (1)
  • useAuthStore (8-171)
web/src/__tests__/stores/approvals.test.ts (2)
web/src/api/types.ts (2)
  • ApprovalItem (221-236)
  • WsEvent (519-524)
web/src/stores/approvals.ts (1)
  • useApprovalStore (7-91)
web/src/stores/tasks.ts (3)
web/src/api/types.ts (6)
  • Task (152-178)
  • TaskFilters (211-217)
  • CreateTaskRequest (180-190)
  • UpdateTaskRequest (192-199)
  • TransitionTaskRequest (201-205)
  • CancelTaskRequest (207-209)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/api/endpoints/tasks.ts (4)
  • createTask (21-24)
  • updateTask (26-29)
  • transitionTask (31-34)
  • cancelTask (36-39)
web/src/__tests__/stores/websocket.test.ts (2)
web/src/stores/websocket.ts (1)
  • useWebSocketStore (23-225)
web/src/api/types.ts (1)
  • WsEvent (519-524)
web/src/api/client.ts (1)
web/src/api/types.ts (2)
  • ApiResponse (94-96)
  • PaginatedResponse (105-107)
web/src/__tests__/stores/budget.test.ts (2)
web/src/api/types.ts (2)
  • CostRecord (331-341)
  • WsEvent (519-524)
web/src/stores/budget.ts (1)
  • useBudgetStore (9-73)
web/src/__tests__/composables/usePolling.test.ts (1)
web/src/composables/usePolling.ts (1)
  • usePolling (7-51)
web/src/api/endpoints/messages.ts (2)
web/src/api/types.ts (3)
  • PaginationParams (552-555)
  • Message (447-458)
  • Channel (462-466)
web/src/api/client.ts (3)
  • apiClient (10-14)
  • unwrapPaginated (62-78)
  • unwrap (50-56)
web/src/api/endpoints/approvals.ts (2)
web/src/api/types.ts (1)
  • ApprovalFilters (257-263)
web/src/api/client.ts (2)
  • unwrapPaginated (62-78)
  • unwrap (50-56)
web/src/__tests__/utils/constants.test.ts (1)
web/src/utils/constants.ts (4)
  • TASK_STATUS_ORDER (23-33)
  • TERMINAL_STATUSES (36-36)
  • VALID_TRANSITIONS (39-49)
  • NAV_ITEMS (55-66)
web/src/__tests__/router/guards.test.ts (2)
web/src/router/guards.ts (1)
  • authGuard (12-36)
web/src/stores/auth.ts (1)
  • useAuthStore (8-171)
web/src/stores/analytics.ts (2)
web/src/api/types.ts (1)
  • OverviewMetrics (372-377)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/api/endpoints/company.ts (2)
web/src/api/types.ts (3)
  • CompanyConfig (392-396)
  • PaginationParams (552-555)
  • Department (381-385)
web/src/api/client.ts (3)
  • apiClient (10-14)
  • unwrap (50-56)
  • unwrapPaginated (62-78)
web/src/stores/messages.ts (2)
web/src/api/types.ts (3)
  • Message (447-458)
  • Channel (462-466)
  • WsEvent (519-524)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/api/endpoints/agents.ts (2)
web/src/api/types.ts (1)
  • PaginationParams (552-555)
web/src/api/client.ts (3)
  • apiClient (10-14)
  • unwrapPaginated (62-78)
  • unwrap (50-56)
web/src/composables/useOptimisticUpdate.ts (1)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/stores/budget.ts (2)
web/src/api/types.ts (4)
  • BudgetConfig (356-363)
  • CostRecord (331-341)
  • AgentSpending (365-368)
  • WsEvent (519-524)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/__tests__/stores/messages.test.ts (1)
web/src/stores/messages.ts (1)
  • useMessageStore (9-77)
web/src/stores/websocket.ts (2)
web/src/api/types.ts (3)
  • WsChannel (492-498)
  • WsEventHandler (548-548)
  • WsEvent (519-524)
web/src/utils/constants.ts (3)
  • WS_MAX_RECONNECT_ATTEMPTS (9-9)
  • WS_RECONNECT_BASE_DELAY (7-7)
  • WS_RECONNECT_MAX_DELAY (8-8)
web/src/__tests__/api/client.test.ts (1)
web/src/api/client.ts (2)
  • unwrap (50-56)
  • unwrapPaginated (62-78)
web/src/stores/agents.ts (3)
web/src/api/types.ts (2)
  • AgentConfig (313-327)
  • WsEvent (519-524)
web/src/utils/constants.ts (1)
  • MAX_PAGE_SIZE (15-15)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/main.ts (2)
web/src/router/index.ts (1)
  • router (42-42)
web/src/primevue-preset.ts (1)
  • primeVueOptions (4-12)
web/src/__tests__/utils/errors.test.ts (1)
web/src/utils/errors.ts (2)
  • isAxiosError (8-10)
  • getErrorMessage (16-66)
web/src/__tests__/utils/format.test.ts (1)
web/src/utils/format.ts (6)
  • formatDate (6-17)
  • formatRelativeTime (22-36)
  • formatCurrency (41-48)
  • formatNumber (53-55)
  • formatUptime (60-69)
  • formatLabel (74-79)
web/src/utils/constants.ts (1)
web/src/api/types.ts (1)
  • TaskStatus (5-14)
web/src/api/endpoints/tasks.ts (2)
web/src/api/types.ts (6)
  • TaskFilters (211-217)
  • Task (152-178)
  • CreateTaskRequest (180-190)
  • UpdateTaskRequest (192-199)
  • TransitionTaskRequest (201-205)
  • CancelTaskRequest (207-209)
web/src/api/client.ts (3)
  • apiClient (10-14)
  • unwrapPaginated (62-78)
  • unwrap (50-56)
web/src/stores/providers.ts (2)
web/src/api/types.ts (1)
  • ProviderConfig (414-418)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/router/guards.ts (1)
web/src/stores/auth.ts (1)
  • useAuthStore (8-171)
web/src/api/endpoints/budget.ts (2)
web/src/api/client.ts (3)
  • apiClient (10-14)
  • unwrap (50-56)
  • unwrapPaginated (62-78)
web/src/api/types.ts (1)
  • PaginationParams (552-555)
web/src/stores/approvals.ts (2)
web/src/api/types.ts (5)
  • ApprovalItem (221-236)
  • ApprovalFilters (257-263)
  • ApproveRequest (249-251)
  • RejectRequest (253-255)
  • WsEvent (519-524)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
🪛 Biome (2.4.6)
web/src/styles/global.css

[error] 3-34: Tailwind-specific syntax is disabled.

(parse)

web/src/stores/websocket.ts

[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)

web/src/main.ts

[error] 23-23: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 23-23: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 25-25: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 25-25: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)

🪛 GitHub Check: CodeQL
web/src/stores/websocket.ts

[warning] 91-91: Log injection
Log entry depends on a user-provided value.


[warning] 99-99: Log injection
Log entry depends on a user-provided value.

🪛 GitHub Check: Dashboard Lint & Type Check
web/src/stores/websocket.ts

[warning] 185-185:
Generic Object Injection Sink


[warning] 180-180:
Generic Object Injection Sink


[warning] 17-17:
Generic Object Injection Sink


[warning] 17-17:
Generic Object Injection Sink

web/src/stores/providers.ts

[warning] 13-13:
Generic Object Injection Sink

🪛 LanguageTool
docs/user_guide.md

[style] ~21-~21: Using many exclamation marks might seem excessive (in this case: 3 exclamation marks for a text that’s 1272 characters long)
Context: ...he UI is planned for a future release. !!! info "Active Development" SynthOrg ...

(EN_EXCESSIVE_EXCLAMATION)

docs/design/operations.md

[grammar] ~969-~969: Please add a punctuation mark at the end of paragraph.
Context: ...# Web UI Features !!! info "In Progress" The Web UI is being built as a Vue...

(PUNCTUATION_PARAGRAPH_END)

🪛 markdownlint-cli2 (0.21.0)
docs/design/operations.md

[warning] 971-971: Code block style
Expected: fenced; Actual: indented

(MD046, code-block-style)

🪛 Stylelint (17.4.0)
web/src/styles/global.css

[error] 3-3: Unexpected unknown at-rule "@theme" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)

Comment on lines +15 to +86
describe('unwrap', () => {
beforeEach(() => {
localStorage.clear()
})

it('extracts data from successful response', () => {
const response = mockResponse({ data: { id: '1', name: 'test' }, error: null, success: true })
const result = unwrap(response)
expect(result).toEqual({ id: '1', name: 'test' })
})

it('throws on error response', () => {
const response = mockResponse({ data: null, error: 'Not found', success: false })
expect(() => unwrap(response)).toThrow('Not found')
})

it('throws on success:false with null data and null error', () => {
const response = mockResponse({ data: null, error: null, success: false })
expect(() => unwrap(response)).toThrow('Unknown API error')
})

it('throws on success:true with null data', () => {
const response = mockResponse({ data: null, error: null, success: true })
expect(() => unwrap(response)).toThrow('Unknown API error')
})
})

describe('unwrapPaginated', () => {
it('extracts paginated data', () => {
const response = mockResponse({
data: [{ id: '1' }, { id: '2' }],
error: null,
success: true,
pagination: { total: 10, offset: 0, limit: 50 },
})
const result = unwrapPaginated(response)
expect(result.data).toHaveLength(2)
expect(result.total).toBe(10)
expect(result.offset).toBe(0)
expect(result.limit).toBe(50)
})

it('throws on error', () => {
const response = mockResponse({
data: null,
error: 'Server error',
success: false,
pagination: null,
})
expect(() => unwrapPaginated(response)).toThrow('Server error')
})

it('throws on success with missing pagination', () => {
const response = mockResponse({
data: [{ id: '1' }],
error: null,
success: true,
pagination: null,
})
expect(() => unwrapPaginated(response)).toThrow('Unexpected API response format')
})

it('throws on success with non-array data', () => {
const response = mockResponse({
data: 'not-an-array',
error: null,
success: true,
pagination: { total: 0, offset: 0, limit: 50 },
})
expect(() => unwrapPaginated(response)).toThrow('Unexpected API response format')
})
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Please add direct interceptor coverage to this suite.

This file exercises unwrap and unwrapPaginated, but the risky behavior in web/src/api/client.ts is the auth header injection and the 401 token-clear/redirect path. Those branches should have at least one dedicated test each.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/__tests__/api/client.test.ts` around lines 15 - 86, Add tests that
cover the axios interceptors in web/src/api/client.ts: create a test that sets a
token in localStorage, obtains the configured axios instance (exported as
apiClient or obtained via the module's setup function), simulate a request
through the request interceptor and assert the Authorization header contains
`Bearer <token>`; and create a test that simulates a 401 response through the
response interceptor (mock a response with status 401 or call the interceptor's
error handler), spy on localStorage.removeItem/clear and on
window.location.assign (or the module function that redirects) and assert the
token is cleared and redirect is invoked. Use jest.spyOn/mocks to inject the
fake token and to invoke the interceptor functions directly or by mocking
axios.request to trigger the interceptors so the tests exercise the auth header
injection and the 401 token-clear/redirect branches.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +53 to +67
function handleWsEvent(event: WsEvent) {
const payload = event.payload as Partial<ApprovalItem> & { id?: string }
switch (event.event_type) {
case 'approval.submitted':
if (
typeof payload.id === 'string' &&
payload.id &&
payload.action_type &&
payload.title &&
!approvals.value.some((a) => a.id === payload.id)
) {
approvals.value = [payload as ApprovalItem, ...approvals.value]
total.value++
}
break
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incomplete validation before casting to ApprovalItem.

The approval.submitted handler validates only id, action_type, and title, but ApprovalItem has many more required fields (description, requested_by, risk_level, status, task_id, metadata, created_at, etc.). Casting the partial payload to ApprovalItem at line 64 may insert an incomplete object into the array, causing runtime errors or rendering issues downstream.

Consider either:

  1. Validating all required fields before inserting, or
  2. Fetching the full approval from the API when a submitted event arrives.
Option 1: Stricter validation
       case 'approval.submitted':
         if (
           typeof payload.id === 'string' &&
           payload.id &&
           payload.action_type &&
           payload.title &&
+          payload.description !== undefined &&
+          payload.requested_by &&
+          payload.risk_level &&
+          payload.status &&
+          payload.created_at &&
           !approvals.value.some((a) => a.id === payload.id)
         ) {
           approvals.value = [payload as ApprovalItem, ...approvals.value]
           total.value++
         }
         break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function handleWsEvent(event: WsEvent) {
const payload = event.payload as Partial<ApprovalItem> & { id?: string }
switch (event.event_type) {
case 'approval.submitted':
if (
typeof payload.id === 'string' &&
payload.id &&
payload.action_type &&
payload.title &&
!approvals.value.some((a) => a.id === payload.id)
) {
approvals.value = [payload as ApprovalItem, ...approvals.value]
total.value++
}
break
function handleWsEvent(event: WsEvent) {
const payload = event.payload as Partial<ApprovalItem> & { id?: string }
switch (event.event_type) {
case 'approval.submitted':
if (
typeof payload.id === 'string' &&
payload.id &&
payload.action_type &&
payload.title &&
payload.description !== undefined &&
payload.requested_by &&
payload.risk_level &&
payload.status &&
payload.created_at &&
!approvals.value.some((a) => a.id === payload.id)
) {
approvals.value = [payload as ApprovalItem, ...approvals.value]
total.value++
}
break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/approvals.ts` around lines 53 - 67, The handler handleWsEvent
currently casts a partial payload to ApprovalItem and pushes it into
approvals.value on 'approval.submitted', but only validates id, action_type, and
title; update this by either validating all required ApprovalItem fields
(description, requested_by, risk_level, status, task_id, metadata, created_at,
etc.) on payload before casting and inserting into approvals.value and
incrementing total.value, or — preferably — call the API to fetch the full
approval record by payload.id (e.g., a getApprovalById / fetchApproval function)
inside the 'approval.submitted' case and push the returned complete ApprovalItem
into approvals.value and increment total.value only after successful fetch;
ensure you guard against duplicates using the existing approvals.value.some
check and handle fetch errors without inserting incomplete objects.

Comment on lines +44 to +53
function connect(token: string) {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return
reconnectExhausted.value = false

currentToken = token
intentionalClose = false
// TODO: Replace with one-time WS ticket endpoint for production security.
// Currently passes JWT as query param which is logged in server/proxy/browser.
const url = `${getWsUrl()}?token=${encodeURIComponent(token)}`
socket = new WebSocket(url)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for related issues or tickets about WS authentication
rg -i "ticket|ws.*auth|websocket.*token" --type=md --type=ts -g '!node_modules' -C2

Repository: Aureliolo/synthorg

Length of output: 1285


🏁 Script executed:

# Check for GitHub issues files and tracking documentation
find . -type f \( -name "*.issue" -o -name "*.github" -o -name "ISSUES*" -o -name "TODO*" -o -name "SECURITY*" \) 2>/dev/null | head -20

Repository: Aureliolo/synthorg

Length of output: 83


🏁 Script executed:

# Search more broadly for any references to "one-time ticket" or related security work
rg "one-time|ticket.*endpoint|ws.*security|websocket.*security" --type=md --type=ts --type=txt -g '!node_modules' -i

Repository: Aureliolo/synthorg

Length of output: 445


🏁 Script executed:

# Check if there's a CHANGELOG or similar documentation that mentions this work
find . -type f \( -name "CHANGELOG*" -o -name "HISTORY*" -o -name "NEWS*" -o -name "ROADMAP*" \) 2>/dev/null

Repository: Aureliolo/synthorg

Length of output: 84


🏁 Script executed:

# Check SECURITY.md file
cat -n ./.github/SECURITY.md

Repository: Aureliolo/synthorg

Length of output: 2060


🏁 Script executed:

# Check CHANGELOG.md
cat -n ./.github/CHANGELOG.md | head -100

Repository: Aureliolo/synthorg

Length of output: 15364


🏁 Script executed:

# Search for any issue tracking in code comments (beyond what we found)
rg "issue|tracking|#\d+" web/src/stores/websocket.ts -A2 -B2

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there's a GitHub issues directory or any issue tracking files
git ls-files | grep -i "issue\|ticket\|bug\|todo" | head -20

Repository: Aureliolo/synthorg

Length of output: 44


Security: JWT token exposed in WebSocket URL query parameter.

The TODO at line 50 correctly identifies this risk. Query parameters are logged by proxies, server access logs, and browser history, making JWT tokens unnecessarily exposed. Replace with a one-time ticket exchange mechanism before any production use. No tracking issue found in the project history for this work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/websocket.ts` around lines 44 - 53, The connect function
currently appends the JWT as a query param to the WebSocket URL (see connect,
currentToken, getWsUrl) which exposes the token; change connect to first call a
backend endpoint to exchange the JWT for a short-lived one-time WS ticket
(implement a helper like fetchWsTicket or getWsTicket) using the existing token,
then open the WebSocket to getWsUrl() with that ticket (or no sensitive data in
query) instead of the raw JWT; ensure currentToken is kept client-side only,
remove encodeURIComponent(token) usage in the URL, handle failures from the
ticket exchange (log/abort) and ensure the ticket is single-use/short-lived on
the backend so production logs no longer contain the JWT.

…ent extraction

- Split CI dashboard-lint into separate lint and type-check jobs
- Add generic type params to all apiClient.get/post calls for type safety
- Extract PlaceholderHome to its own SFC, simplify router imports
- Fix StatusBadge type-safe indexing with proper key casts
- Fix LoginPage to use auth.mustChangePassword (computed) not result field
- Add ESLint multi-word exemptions for Sidebar.vue and Topbar.vue
- Add error path tests for transitionTask and cancelTask
- Add StatusBadge color class tests and unknown value fallback test
- Add EmptyState action slot rendering tests
- Add usePolling boundary validation tests (NaN, Infinity, below min)
- Expand budget store tests (fetchConfig, fetchRecords, fetchAgentSpending, invalid payload)
- Add client interceptor tests for JWT attachment
- Fix format tests for future date and round-hour uptime behavior changes
- Fix messages test for WS total no-increment behavior
- Fix useOptimisticUpdate test for sanitized rollback error logging
- Remove unused mockGetTask from tasks test
try {
dispatchEvent(msg as unknown as WsEvent)
} catch (handlerErr) {
console.error('WebSocket event handler error:', sanitizeLogValue(handlerErr), 'Event type:', sanitizeLogValue(msg.event_type, 100))

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 22 days ago

In general, to fix log injection issues you must (1) sanitize user-controlled strings so they cannot introduce unintended structure into logs (e.g., remove or escape line breaks and control chars) and (2) make it unambiguous in the log output which parts are user input, typically by surrounding them with quotes or brackets or by adding a clear label.

In this codebase, we already have sanitizeLogValue that strips control characters and truncates the value. The best minimal fix, without changing existing behavior, is to keep using this sanitizer but make logged user-controlled values clearly marked. Specifically, we can wrap sanitizeLogValue(msg.error) and sanitizeLogValue(msg.event_type, 100) in explicit delimiters (for example, single quotes) in the log message. That way, even if the value contains sequences that visually resemble log prefixes, they will appear within quotes and cannot be mistaken for separate log entries. No new imports or helper functions are required; we just update the two console.error calls in web/src/stores/websocket.ts around line 90–100.

Concretely:

  • In the if (msg.error) block (around line 90), change the log to console.error("WebSocket error:", "'" + sanitizeLogValue(msg.error) + "'") (or equivalent template-string form).
  • In the catch (handlerErr) block (around line 95–100), change the log to include quoted versions of both the error and the event_type field, for example:
    console.error("WebSocket event handler error:", "'" + sanitizeLogValue(handlerErr) + "'", "Event type:", "'" + sanitizeLogValue(msg.event_type, 100) + "'").

This preserves all existing functionality (still logs the same information, same sanitizer) while making the user-controlled fragments explicitly delimited for safer logging.

Suggested changeset 1
web/src/stores/websocket.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/web/src/stores/websocket.ts b/web/src/stores/websocket.ts
--- a/web/src/stores/websocket.ts
+++ b/web/src/stores/websocket.ts
@@ -88,7 +88,7 @@
       }
 
       if (msg.error) {
-        console.error('WebSocket error:', sanitizeLogValue(msg.error))
+        console.error('WebSocket error:', `'${sanitizeLogValue(msg.error)}'`)
         return
       }
 
@@ -96,7 +96,12 @@
         try {
           dispatchEvent(msg as unknown as WsEvent)
         } catch (handlerErr) {
-          console.error('WebSocket event handler error:', sanitizeLogValue(handlerErr), 'Event type:', sanitizeLogValue(msg.event_type, 100))
+          console.error(
+            'WebSocket event handler error:',
+            `'${sanitizeLogValue(handlerErr)}'`,
+            'Event type:',
+            `'${sanitizeLogValue(msg.event_type, 100)}'`,
+          )
         }
       }
     }
EOF
@@ -88,7 +88,7 @@
}

if (msg.error) {
console.error('WebSocket error:', sanitizeLogValue(msg.error))
console.error('WebSocket error:', `'${sanitizeLogValue(msg.error)}'`)
return
}

@@ -96,7 +96,12 @@
try {
dispatchEvent(msg as unknown as WsEvent)
} catch (handlerErr) {
console.error('WebSocket event handler error:', sanitizeLogValue(handlerErr), 'Event type:', sanitizeLogValue(msg.event_type, 100))
console.error(
'WebSocket event handler error:',
`'${sanitizeLogValue(handlerErr)}'`,
'Event type:',
`'${sanitizeLogValue(msg.event_type, 100)}'`,
)
}
}
}
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

♻️ Duplicate comments (4)
web/src/stores/budget.ts (1)

10-14: ⚠️ Potential issue | 🟠 Major

Strengthen CostRecord validation before mutating store state.

The current guard validates only two fields, but Line 65 stores payload as a full CostRecord. Malformed WS payloads can still enter records.

Suggested fix
 function isCostRecord(payload: unknown): payload is CostRecord {
   if (typeof payload !== 'object' || payload === null) return false
   const p = payload as Record<string, unknown>
-  return typeof p.agent_id === 'string' && typeof p.cost_usd === 'number'
+  return (
+    typeof p.agent_id === 'string' &&
+    typeof p.task_id === 'string' &&
+    typeof p.provider === 'string' &&
+    typeof p.model === 'string' &&
+    typeof p.input_tokens === 'number' &&
+    typeof p.output_tokens === 'number' &&
+    typeof p.cost_usd === 'number' &&
+    typeof p.timestamp === 'string' &&
+    (p.call_category === null ||
+      p.call_category === 'productive' ||
+      p.call_category === 'coordination' ||
+      p.call_category === 'system')
+  )
 }

Also applies to: 64-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/budget.ts` around lines 10 - 14, isCostRecord currently only
checks agent_id and cost_usd so malformed WS payloads can still be pushed into
the store; extend isCostRecord to verify all properties declared on the
CostRecord interface (check presence and correct typeof for each required key,
e.g., agent_id, cost_usd and any timestamp/run identifiers or optional fields
per the interface) and then gate the mutation that writes to records using this
strengthened guard (ensure the code path that does the push into records only
runs when isCostRecord(payload) is true and otherwise rejects/logs the payload).
web/src/stores/websocket.ts (2)

177-195: ⚠️ Potential issue | 🟠 Major

unsubscribe() can replay channels the caller already removed.

Lines 182-190 intentionally keep grouped subscriptions like ['tasks', 'agents'] when callers pass unsubscribe(['tasks']), but Line 195 still sends an unsubscribe for tasks. After that, the live socket drops tasks while activeSubscriptions still contains it, so the next reconnect silently re-subscribes the channel the caller just removed. Either reject partial unsubscriptions or track subscriptions at channel granularity so local state matches the wire protocol.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/websocket.ts` around lines 177 - 195, The unsubscribe function
currently only removes subscription entries when all their channels are covered,
causing a mismatch between local state
(activeSubscriptions/pendingSubscriptions) and the wire when callers unsubscribe
a subset; fix unsubscribe to operate at channel granularity: in
unsubscribe(channels) iterate activeSubscriptions and pendingSubscriptions and
for each Subscription object remove any channels found in the provided channels
set (update the Subscription.channels array in-place), and if a
Subscription.channels becomes empty splice it out; then, if socket &&
socket.readyState === WebSocket.OPEN, send the unsubscribe message for the
requested channels as before. Update references to activeSubscriptions,
pendingSubscriptions, and unsubscribe to reflect the per-channel removal so
reconnect logic will not re-subscribe channels the caller removed.

44-53: ⚠️ Potential issue | 🔴 Critical

Do not ship the JWT in the WebSocket URL.

Line 52 still puts the bearer token in the query string. That leaks credentials into browser history, reverse-proxy access logs, and URL-based telemetry, so the TODO is not sufficient for release. Use a short-lived WS ticket or another handshake that keeps the JWT out of the URL.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/websocket.ts` around lines 44 - 53, The connect function
currently appends the JWT to the WebSocket URL (see connect(token), currentToken
and getWsUrl()), which leaks credentials; remove the token from the query string
and implement a handshake that keeps the JWT out of the URL: either (A) call a
short‑lived WS ticket endpoint (e.g., fetchWSTicket(token)) and use that ticket
in the URL instead of the JWT, or (B) open the socket to getWsUrl() without
token and send the JWT as the first authenticated message over the socket (e.g.,
an "auth" payload) and block further processing until the server ACKs; update
connect(token) to set currentToken and intentionalClose as before, create the
socket without the token in the URL, perform the chosen handshake step, and only
treat the connection as authenticated after a successful handshake. Ensure
server/client agree on the handshake method and remove any use of
encodeURIComponent(token) in the URL.
web/src/__tests__/components/StatusBadge.test.ts (1)

20-25: ⚠️ Potential issue | 🟡 Minor

Add a risk color-class assertion to fully cover getColorClass() branches.

Current risk test (Line 20–25) verifies label only; it won’t fail if risk mapping falls back incorrectly. This is still the same branch-coverage gap raised earlier.

🧪 Suggested test update
   it('renders risk type', () => {
     const wrapper = mount(StatusBadge, {
       props: { value: 'high', type: 'risk' },
     })
     expect(wrapper.text()).toContain('High')
+    const tag = wrapper.find('.p-tag')
+    expect(tag.classes()).toContain('bg-orange-600')
+    expect(tag.classes()).toContain('text-orange-100')
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/__tests__/components/StatusBadge.test.ts` around lines 20 - 25, The
test for StatusBadge currently only checks the rendered label and misses
asserting the CSS class from getColorClass(), so update the test to also assert
the badge element includes the color class returned by getColorClass for the
'high'/'risk' inputs; e.g., after mounting StatusBadge, call the component
instance's getColorClass('high','risk')
(wrapper.vm.getColorClass('high','risk')) and assert wrapper.classes() or
wrapper.find('.status-badge').classes() contains that value so the risk branch
of getColorClass() is exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docker/web/Dockerfile`:
- Around line 20-21: The FROM line using
nginxinc/nginx-unprivileged:1.29.5-alpine is pinned to an outdated digest
(sha256:aec540f08f99...), causing a mismatch; update the Dockerfile by either
replacing that digest with the current digest for the 1.29.5-alpine tag (e.g.,
the digest reported by Docker Hub) or remove the digest to rely on the tag if
you prefer floating updates, and document the choice; locate the offending
directive (the FROM nginxinc/nginx-unprivileged:1.29.5-alpine@sha256:... line)
and change it accordingly, ensuring CI/builds are re-run to verify the image
resolves.

In `@web/eslint.config.js`:
- Around line 27-31: Replace the per-file disabling of
'vue/multi-word-component-names' with a targeted ignores list: remove the files
block that turns the rule off for 'src/App.vue',
'src/components/layout/Sidebar.vue', and 'src/components/layout/Topbar.vue' and
instead add an ignores entry for the rule 'vue/multi-word-component-names'
listing 'Sidebar' and 'Topbar' (leave 'App' to rely on the rule's built-in
root-component exemption); update eslint.config.js to configure the rule via its
ignores option rather than turning it off for whole files.
- Around line 23-25: Update the ESLint rules to enforce 'vue/no-v-html' as an
error instead of a warning by changing the rule value in the rules object (the
'vue/no-v-html' entry in eslint.config.js) from 'warn' to 'error'; do not remove
the rule—if specific exceptions are required, document and apply inline
eslint-disable comments at the offending v-html usages rather than weakening the
global rule.
- Around line 1-21: The config is missing baseline JS/TS presets—add eslint's
built-in recommended and typescript-eslint's recommended configs to the exported
array (reference eslint.configs.recommended and
typescript-eslint.configs.recommended) so standard JS/TS rules are applied; also
remove the separate tsParser import and instead configure the parser via the
typescript-eslint plugin in the per-file languageOptions for both '**/*.vue' and
'**/*.ts' entries (consolidate those entries so they use the same parser
configuration) while keeping pluginVue.configs['flat/essential'] and
pluginSecurity.configs.recommended in place.

In `@web/nginx.conf`:
- Around line 22-25: CSP connect-src currently uses scheme-only ws: and wss:
which allows WebSocket connections to any host; update the add_header
Content-Security-Policy directive to restrict WebSocket origins to same-origin
or explicit hostnames (replace ws: wss: with 'self' and/or the backend host like
wss://your-backend-host) so only intended WebSocket endpoints are permitted;
modify the add_header Content-Security-Policy line in nginx.conf (the directive
named "add_header Content-Security-Policy") to use host-based sources instead of
scheme-only sources.

In `@web/src/__tests__/api/client.test.ts`:
- Around line 15-45: Add a new test for the response interceptor that verifies
401 handling: set localStorage auth_token, spy on window.location.assign,
extract the response interceptors via apiClient.interceptors.response.handlers
and get the first handler's rejected function, invoke it with an error object {
response: { status: 401 } } and assert the promise rejects, then assert
localStorage.getItem('auth_token') is null and that window.location.assign was
called; restore the spy at the end. Reference the
apiClient.interceptors.response handlers and the rejected error handler when
adding the test.
- Around line 24-44: The tests currently reach into Axios internals via
apiClient.interceptors.request.handlers which is brittle; instead extract the
header-setting logic into a pure function (e.g., addAuthHeader(config) or
getAuthHeaderFromStorage) and update the interceptor to call that function, then
unit-test that pure function directly with plain config objects; for
integration-style tests use MSW to mock network requests against apiClient and
assert that outgoing requests include or omit the Authorization header based on
localStorage state, ensuring tests reference apiClient and makeConfig only at
the network boundary and never access interceptors.handlers.

In `@web/src/__tests__/composables/useOptimisticUpdate.test.ts`:
- Around line 8-94: Add a test for the re-entrancy guard in useOptimisticUpdate:
call execute (from useOptimisticUpdate) with an optimistic apply function (vi.fn
returning a rollback) and a serverAction that resolves after a delay, assert
pending.value becomes true, then call execute again while pending and assert it
immediately returns null and does not call the optimistic or serverAction
callbacks a second time (applyOptimistic and serverAction should have been
called only once), finally await the firstPromise to resolve and assert its
result; reference execute, pending, applyOptimistic, and serverAction when
implementing the test.

In `@web/src/__tests__/stores/budget.test.ts`:
- Around line 45-46: Update the test fixtures so the mocked API responses
exactly match the declared models (BudgetConfig and AgentSpending) used by the
code: replace any incorrect keys (e.g., total_cost -> total_cost_usd,
daily_limit -> daily_limit_usd) and ensure fields like agent_id, period, and
currency match the real model shape; update mockGetBudgetConfig and the
corresponding mockGetAgentSpending (the mocks around lines ~45 and ~93) to
return objects conforming to BudgetConfig and AgentSpending so the tests
exercise the real contract.

In `@web/src/api/endpoints/budget.ts`:
- Around line 9-14: The function listCostRecords lacks an explicit return type
annotation; update its signature to include the correct return type (the same
style used elsewhere) by annotating it as returning
Promise<PaginatedResult<CostRecord>> (or the actual exported type alias used in
the module for unwrapPaginated results), e.g., change the declaration of
listCostRecords to include that Promise<...> return type and keep the body using
apiClient.get<PaginatedResponse<CostRecord>> and
unwrapPaginated<CostRecord>(response) unchanged so types remain consistent with
other endpoint functions.

In `@web/src/api/endpoints/providers.ts`:
- Around line 10-18: The loop in listProviders can be hardened against
prototype-pollution/object-injection by filtering keys against the same denylist
used in the provider store: import or reference the UNSAFE_KEYS set used in
web/src/stores/providers.ts and skip any key present in UNSAFE_KEYS when
iterating raw (and also guard with Object.prototype.hasOwnProperty.call(raw,
key) to ensure only own properties are processed); continue to call
stripSecrets(provider) for allowed keys and add them to result.

In `@web/src/stores/auth.ts`:
- Around line 137-140: The catch block inside changePassword currently just
rethrows the original error despite the comment saying it should normalize;
either remove the redundant catch entirely or implement normalization: locate
the changePassword function and its try/catch/finally, and either delete the
catch clause that only does "throw err" so the original error propagates, or
replace it with logic that maps known error types to user-friendly messages
(e.g., wrap err with a new Error('Friendly message') or set err.message) before
rethrowing so callers receive a normalized error.

In `@web/src/stores/messages.ts`:
- Around line 50-62: The WebSocket handler handleWsEvent currently blindly casts
event.payload to Message which can throw or corrupt state if payload is
malformed; add a small runtime guard before using fields: after const message =
event.payload as unknown as Message, verify required properties (e.g., typeof
message?.id === 'string' || typeof message?.id === 'number', typeof
message?.channel === 'string') and only then proceed to the
activeChannel/messaging logic (messages.value = [...messages.value,
message].slice(-MAX_WS_MESSAGES)); if validation fails, early-return or log and
ignore the payload so fetchMessages() remains the authoritative source for total
counts.

In `@web/src/stores/tasks.ts`:
- Around line 57-105: Local mutations in createTask, updateTask, transitionTask,
and cancelTask blindly append/replace tasks.value causing filtered or paginated
views to drift; change each function to either (a) check the currentFilters (or
a helper like matchesFilters) and only insert/replace the task in tasks.value
when the returned task satisfies currentFilters, or (b) if currentFilters are
active, perform a post-mutation refetch of the current REST page instead of
mutating tasks.value directly; apply this guard to createTask (avoid appending
created tasks that don't match filters) and to
updateTask/transitionTask/cancelTask (only replace in-place when the updated
task still matches currentFilters, otherwise remove it or refetch).

In `@web/src/stores/websocket.ts`:
- Around line 7-8: The regex in sanitizeLogValue uses control-character escapes
that trigger the linter; replace the character-class /[\x00-\x1f\x7f]/g with a
Unicode property class to match control characters instead (e.g. use /\p{Cc}/gu)
so the pattern avoids literal control escapes and add the 'u' flag; keep the
rest of sanitizeLogValue (String(value).replace(...).slice(0, max)) intact.

In `@web/src/utils/format.ts`:
- Around line 60-68: formatUptime currently accepts negative or non-finite
seconds and may produce invalid output; update the function (formatUptime) to
validate the input by checking Number.isFinite(seconds) and seconds >= 0, and if
the check fails either clamp seconds to 0 or return a normalized string like
"0m" (instead of proceeding), then continue computing days/hours/mins from the
validated value so negative/NaN/Infinity inputs cannot produce bad durations.

In `@web/src/views/LoginPage.vue`:
- Around line 18-33: The client-side lockout (attempts, lockedUntil, now,
locked, checkAndClearLockout, clockTimer) is only UI-level and can be
bypassed—update LoginPage.vue to rely on server-enforced lockout: on mount fetch
the account/session lock status and attempt count from an auth endpoint and
populate lockedUntil/attempts, include these values in every login request and
handle server responses that indicate lock or remaining attempts, and on login
attempts always use the server response to update lockedUntil/attempts (keeping
the reactive UI clock only for display); ensure the login API enforces the true
limit and returns clear lock expiration and attempt data for the client to
consume.

---

Duplicate comments:
In `@web/src/__tests__/components/StatusBadge.test.ts`:
- Around line 20-25: The test for StatusBadge currently only checks the rendered
label and misses asserting the CSS class from getColorClass(), so update the
test to also assert the badge element includes the color class returned by
getColorClass for the 'high'/'risk' inputs; e.g., after mounting StatusBadge,
call the component instance's getColorClass('high','risk')
(wrapper.vm.getColorClass('high','risk')) and assert wrapper.classes() or
wrapper.find('.status-badge').classes() contains that value so the risk branch
of getColorClass() is exercised.

In `@web/src/stores/budget.ts`:
- Around line 10-14: isCostRecord currently only checks agent_id and cost_usd so
malformed WS payloads can still be pushed into the store; extend isCostRecord to
verify all properties declared on the CostRecord interface (check presence and
correct typeof for each required key, e.g., agent_id, cost_usd and any
timestamp/run identifiers or optional fields per the interface) and then gate
the mutation that writes to records using this strengthened guard (ensure the
code path that does the push into records only runs when isCostRecord(payload)
is true and otherwise rejects/logs the payload).

In `@web/src/stores/websocket.ts`:
- Around line 177-195: The unsubscribe function currently only removes
subscription entries when all their channels are covered, causing a mismatch
between local state (activeSubscriptions/pendingSubscriptions) and the wire when
callers unsubscribe a subset; fix unsubscribe to operate at channel granularity:
in unsubscribe(channels) iterate activeSubscriptions and pendingSubscriptions
and for each Subscription object remove any channels found in the provided
channels set (update the Subscription.channels array in-place), and if a
Subscription.channels becomes empty splice it out; then, if socket &&
socket.readyState === WebSocket.OPEN, send the unsubscribe message for the
requested channels as before. Update references to activeSubscriptions,
pendingSubscriptions, and unsubscribe to reflect the per-channel removal so
reconnect logic will not re-subscribe channels the caller removed.
- Around line 44-53: The connect function currently appends the JWT to the
WebSocket URL (see connect(token), currentToken and getWsUrl()), which leaks
credentials; remove the token from the query string and implement a handshake
that keeps the JWT out of the URL: either (A) call a short‑lived WS ticket
endpoint (e.g., fetchWSTicket(token)) and use that ticket in the URL instead of
the JWT, or (B) open the socket to getWsUrl() without token and send the JWT as
the first authenticated message over the socket (e.g., an "auth" payload) and
block further processing until the server ACKs; update connect(token) to set
currentToken and intentionalClose as before, create the socket without the token
in the URL, perform the chosen handshake step, and only treat the connection as
authenticated after a successful handshake. Ensure server/client agree on the
handshake method and remove any use of encodeURIComponent(token) in the URL.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9421b40f-941c-43ad-829b-872986b8e4b7

📥 Commits

Reviewing files that changed from the base of the PR and between a2857ac and 7253116.

📒 Files selected for processing (45)
  • .github/workflows/ci.yml
  • docker/web/Dockerfile
  • web/eslint.config.js
  • web/index.html
  • web/nginx.conf
  • web/src/__tests__/api/client.test.ts
  • web/src/__tests__/components/EmptyState.test.ts
  • web/src/__tests__/components/StatusBadge.test.ts
  • web/src/__tests__/composables/useOptimisticUpdate.test.ts
  • web/src/__tests__/composables/usePolling.test.ts
  • web/src/__tests__/stores/budget.test.ts
  • web/src/__tests__/stores/messages.test.ts
  • web/src/__tests__/stores/tasks.test.ts
  • web/src/__tests__/utils/format.test.ts
  • web/src/api/client.ts
  • web/src/api/endpoints/agents.ts
  • web/src/api/endpoints/approvals.ts
  • web/src/api/endpoints/auth.ts
  • web/src/api/endpoints/budget.ts
  • web/src/api/endpoints/company.ts
  • web/src/api/endpoints/messages.ts
  • web/src/api/endpoints/providers.ts
  • web/src/api/endpoints/tasks.ts
  • web/src/components/common/StatusBadge.vue
  • web/src/components/layout/ConnectionStatus.vue
  • web/src/components/layout/Sidebar.vue
  • web/src/composables/useAuth.ts
  • web/src/composables/useOptimisticUpdate.ts
  • web/src/composables/usePolling.ts
  • web/src/main.ts
  • web/src/router/index.ts
  • web/src/stores/auth.ts
  • web/src/stores/budget.ts
  • web/src/stores/company.ts
  • web/src/stores/messages.ts
  • web/src/stores/providers.ts
  • web/src/stores/tasks.ts
  • web/src/stores/websocket.ts
  • web/src/styles/theme.ts
  • web/src/utils/constants.ts
  • web/src/utils/errors.ts
  • web/src/utils/format.ts
  • web/src/views/LoginPage.vue
  • web/src/views/PlaceholderHome.vue
  • web/src/views/SetupPage.vue
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Greptile Review
  • GitHub Check: Test (Python 3.14)
🧰 Additional context used
📓 Path-based instructions (2)
.github/workflows/*.yml

📄 CodeRabbit inference engine (CLAUDE.md)

CI: Jobs (lint + type-check + test in parallel) → ci-pass gate. Pages workflow exports OpenAPI schema, builds Astro landing + Zensical docs, merges, deploys to GitHub Pages on push to main.

Files:

  • .github/workflows/ci.yml
docker/**

📄 CodeRabbit inference engine (CLAUDE.md)

Docker: Backend 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened. Web: nginxinc/nginx-unprivileged, SPA routing, API/WebSocket proxy to backend. All Docker files in docker/ directory.

Files:

  • docker/web/Dockerfile
🧠 Learnings (3)
📚 Learning: 2026-03-13T06:54:28.690Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.690Z
Learning: Applies to .github/workflows/*.yml : CI: Jobs (lint + type-check + test in parallel) → ci-pass gate. Pages workflow exports OpenAPI schema, builds Astro landing + Zensical docs, merges, deploys to GitHub Pages on push to main.

Applied to files:

  • .github/workflows/ci.yml
📚 Learning: 2026-03-13T06:54:28.702Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.702Z
Learning: Applies to .github/workflows/docker.yml : Docker CI: `.github/workflows/docker.yml` builds backend + web images, pushes to GHCR, signs with cosign. Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass.

Applied to files:

  • .github/workflows/ci.yml
  • docker/web/Dockerfile
📚 Learning: 2026-03-13T06:54:28.702Z
Learnt from: CR
Repo: Aureliolo/synthorg PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-13T06:54:28.702Z
Learning: Applies to docker/** : Docker: Backend 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened. Web: `nginxinc/nginx-unprivileged`, SPA routing, API/WebSocket proxy to backend. All Docker files in `docker/` directory.

Applied to files:

  • docker/web/Dockerfile
🧬 Code graph analysis (25)
web/src/api/endpoints/agents.ts (2)
web/src/api/types.ts (1)
  • PaginationParams (552-555)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrapPaginated (66-82)
  • unwrap (54-60)
web/src/__tests__/api/client.test.ts (1)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrap (54-60)
  • unwrapPaginated (66-82)
web/src/api/endpoints/messages.ts (2)
web/src/api/types.ts (5)
  • PaginationParams (552-555)
  • Message (447-458)
  • PaginatedResponse (105-107)
  • Channel (462-466)
  • ApiResponse (94-96)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrapPaginated (66-82)
  • unwrap (54-60)
web/src/stores/messages.ts (2)
web/src/api/types.ts (3)
  • Message (447-458)
  • Channel (462-466)
  • WsEvent (519-524)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/__tests__/utils/format.test.ts (1)
web/src/utils/format.ts (6)
  • formatDate (6-17)
  • formatRelativeTime (22-36)
  • formatCurrency (41-48)
  • formatNumber (53-55)
  • formatUptime (60-69)
  • formatLabel (74-79)
web/src/main.ts (1)
web/src/primevue-preset.ts (1)
  • primeVueOptions (4-12)
web/src/__tests__/stores/budget.test.ts (2)
web/src/api/types.ts (2)
  • CostRecord (331-341)
  • WsEvent (519-524)
web/src/stores/budget.ts (1)
  • useBudgetStore (16-82)
web/src/stores/providers.ts (2)
web/src/api/types.ts (1)
  • ProviderConfig (414-418)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/composables/useOptimisticUpdate.ts (1)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/stores/budget.ts (2)
web/src/api/types.ts (4)
  • CostRecord (331-341)
  • BudgetConfig (356-363)
  • AgentSpending (365-368)
  • WsEvent (519-524)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/__tests__/stores/tasks.test.ts (2)
web/src/api/types.ts (2)
  • Task (152-178)
  • WsEvent (519-524)
web/src/stores/tasks.ts (1)
  • useTaskStore (15-150)
web/src/api/client.ts (2)
web/src/router/index.ts (1)
  • router (34-34)
web/src/api/types.ts (2)
  • ApiResponse (94-96)
  • PaginatedResponse (105-107)
web/src/__tests__/composables/usePolling.test.ts (1)
web/src/composables/usePolling.ts (1)
  • usePolling (9-56)
web/src/utils/constants.ts (1)
web/src/api/types.ts (1)
  • TaskStatus (5-14)
web/src/composables/useAuth.ts (1)
web/src/utils/constants.ts (1)
  • WRITE_ROLES (52-52)
web/src/api/endpoints/company.ts (2)
web/src/api/types.ts (5)
  • CompanyConfig (392-396)
  • ApiResponse (94-96)
  • PaginationParams (552-555)
  • Department (381-385)
  • PaginatedResponse (105-107)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrap (54-60)
  • unwrapPaginated (66-82)
web/src/stores/company.ts (3)
web/src/api/types.ts (2)
  • CompanyConfig (392-396)
  • Department (381-385)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
web/src/utils/constants.ts (1)
  • MAX_PAGE_SIZE (15-15)
web/src/api/endpoints/auth.ts (1)
web/src/api/client.ts (2)
  • apiClient (12-16)
  • unwrap (54-60)
web/src/api/endpoints/providers.ts (1)
web/src/api/client.ts (2)
  • apiClient (12-16)
  • unwrap (54-60)
web/src/api/endpoints/approvals.ts (2)
web/src/api/types.ts (1)
  • ApprovalFilters (257-263)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrapPaginated (66-82)
  • unwrap (54-60)
web/src/stores/websocket.ts (2)
web/src/api/types.ts (3)
  • WsChannel (492-498)
  • WsEventHandler (548-548)
  • WsEvent (519-524)
web/src/utils/constants.ts (3)
  • WS_MAX_RECONNECT_ATTEMPTS (9-9)
  • WS_RECONNECT_BASE_DELAY (7-7)
  • WS_RECONNECT_MAX_DELAY (8-8)
web/src/api/endpoints/tasks.ts (2)
web/src/api/types.ts (8)
  • TaskFilters (211-217)
  • PaginatedResponse (105-107)
  • Task (152-178)
  • ApiResponse (94-96)
  • CreateTaskRequest (180-190)
  • UpdateTaskRequest (192-199)
  • TransitionTaskRequest (201-205)
  • CancelTaskRequest (207-209)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrapPaginated (66-82)
  • unwrap (54-60)
web/src/api/endpoints/budget.ts (2)
web/src/api/client.ts (3)
  • apiClient (12-16)
  • unwrap (54-60)
  • unwrapPaginated (66-82)
web/src/api/types.ts (1)
  • PaginationParams (552-555)
web/src/router/index.ts (1)
web/src/router/guards.ts (1)
  • authGuard (12-36)
web/src/__tests__/composables/useOptimisticUpdate.test.ts (1)
web/src/composables/useOptimisticUpdate.ts (1)
  • useOptimisticUpdate (14-46)
🪛 Biome (2.4.6)
web/src/main.ts

[error] 23-23: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 23-23: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 25-25: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 25-25: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)

web/src/stores/websocket.ts

[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)

🪛 GitHub Check: CodeQL
web/src/stores/websocket.ts

[warning] 91-91: Log injection
Log entry depends on a user-provided value.


[warning] 99-99: Log injection
Log entry depends on a user-provided value.

🪛 GitHub Check: Dashboard Lint
web/src/stores/providers.ts

[warning] 16-16:
Generic Object Injection Sink

web/src/api/endpoints/providers.ts

[warning] 15-15:
Generic Object Injection Sink

web/src/stores/websocket.ts

[warning] 188-188:
Generic Object Injection Sink


[warning] 183-183:
Generic Object Injection Sink


[warning] 17-17:
Generic Object Injection Sink


[warning] 17-17:
Generic Object Injection Sink

🔇 Additional comments (48)
web/src/__tests__/components/EmptyState.test.ts (1)

1-57: LGTM! Test suite is comprehensive and well-structured.

The tests cover all key aspects of the EmptyState component: required props (title), optional props (message, icon), accessibility attributes (aria-hidden), and slot rendering (action slot with conditional container). The action slot test (lines 39-47) addresses the previously flagged gap in coverage.

.github/workflows/ci.yml (6)

102-120: Dashboard lint isolation looks correct.

This cleanly keeps dashboard lint independent and parallelizable from other checks.

As per coding guidelines "CI: Jobs (lint + type-check + test in parallel) → ci-pass gate."


121-139: Dashboard type-check split is correctly implemented.

Good separation from lint; failures in one no longer hide output from the other.

As per coding guidelines "CI: Jobs (lint + type-check + test in parallel) → ci-pass gate."


140-158: Dashboard test job wiring is solid.

The dedicated test execution with coverage is correctly scoped to web/.


159-178: Dashboard build job is properly isolated.

This gives clear build-signal separation from lint/type-check/test.


179-198: Security audit job is correctly integrated.

Having a dedicated dashboard audit step improves visibility of dependency risk.


202-225: ci-pass gate now correctly includes dashboard checks.

The aggregated needs and failure condition enforce a single, accurate pass/fail outcome for all required jobs.

As per coding guidelines "CI: Jobs (lint + type-check + test in parallel) → ci-pass gate."

docker/web/Dockerfile (3)

8-18: LGTM! Builder stage follows security best practices.

Non-root build user with proper ownership chaining (--chown=build:build) prevents permission issues during npm ci and build steps. Using npm ci ensures reproducible, lockfile-based installs.


35-36: Healthcheck configuration is appropriate.

Exec form CMD with wget --spider is correct for Alpine-based images. Parameters (10s interval, 3s timeout, 3 retries, 5s start period) provide reasonable startup tolerance.


30-31: No action required — nginx.conf already includes proper SPA routing and proxy configuration.

The file contains try_files $uri $uri/ /index.html; for SPA routing (line 30), API proxy at /api/ (line 50), and WebSocket proxy at /api/v1/ws (line 35) with correct upstream configuration. All coding guideline requirements are met.

			> Likely an incorrect or invalid review comment.
web/src/composables/usePolling.ts (2)

9-12: LGTM! Input validation properly addresses edge cases.

The validation at lines 10-12 correctly guards against invalid intervalMs values (non-finite, negative, or below the minimum threshold), preventing runaway polling. This addresses the previous review feedback.


16-43: Well-designed non-overlapping polling mechanism.

The setTimeout-based recursive scheduling in scheduleTick ensures the next tick only starts after the previous async call completes. Combined with the idempotent start() guard and immediate first execution, this is a robust implementation.

web/src/__tests__/composables/usePolling.test.ts (2)

113-120: Boundary tests for invalid intervals properly added.

Tests at lines 113-119 validate that usePolling throws for intervals below the minimum (50ms) and for non-finite values (NaN, Infinity). This addresses the previous review feedback requesting boundary test coverage.


14-111: Comprehensive test coverage for polling behavior.

The suite effectively tests all key behaviors: immediate execution, interval-based polling, stop functionality, idempotent start, error handling with continued polling, and non-overlapping async calls. The use of fake timers with advanceTimersByTimeAsync appropriately handles the async nature of the composable.

web/nginx.conf (1)

33-46: WebSocket proxy configuration looks correct.

The WebSocket location block is properly ordered before the generic /api/ block, uses HTTP/1.1 with Upgrade/Connection headers, and has an appropriately long proxy_read_timeout (3600s) for persistent connections.

web/src/__tests__/utils/format.test.ts (1)

1-113: Solid test coverage for formatting utilities.

The tests appropriately handle locale sensitivity (using toContain and toMatch where exact output may vary) and cover important edge cases: null/undefined inputs, invalid dates, zero values, and the formatUptime logic for omitting trailing 0m. Test assertions align correctly with the implementation.

web/src/utils/errors.ts (2)

8-10: Using Axios's official type guard.

The implementation now uses axios.isAxiosError(error) instead of a manual property check, addressing the previous review feedback for stronger compatibility with Axios internals.


16-66: Well-structured error message extraction with appropriate safeguards.

The function properly:

  • Surfaces backend validation messages for 4xx errors
  • Provides user-friendly messages for common HTTP status codes
  • Detects network errors (no response)
  • Returns generic messages for 5xx to avoid leaking server internals
  • Filters potentially unsafe Error.message content (JSON-like strings, overly long messages)
web/src/composables/useOptimisticUpdate.ts (1)

18-43: Re-entrancy guard and sanitized logging properly implemented.

The implementation addresses previous review feedback:

  • Line 22: Guards against re-entrant calls by returning null if pending.value is already true
  • Lines 35 and 38: Console errors now log sanitized messages via getErrorMessage() instead of raw error objects

The optimistic update pattern is correctly implemented with proper rollback handling.

web/src/__tests__/api/client.test.ts (1)

47-117: Comprehensive coverage for unwrap and unwrapPaginated helpers.

The tests cover all important scenarios: successful extraction, error responses, edge cases with null data, missing pagination, and invalid data types. Assertions align correctly with the implementation.

web/src/api/client.ts (3)

8-16: LGTM! BASE_URL normalization correctly implemented.

The normalization logic properly handles trailing slashes and duplicate /api/v1 suffixes, addressing the previous review concern about misconfiguration issues.


30-48: LGTM! 401 handling with SPA-friendly navigation.

The dynamic import pattern for the router avoids circular dependencies while maintaining SPA navigation semantics. The fallback to window.location.href on import failure is a good safety net.


54-82: LGTM! Properly typed envelope unwrapping.

Both unwrap and unwrapPaginated correctly validate response structure and throw meaningful errors. The validation in unwrapPaginated for pagination object and array data prevents cryptic TypeErrors downstream.

web/src/api/endpoints/auth.ts (1)

1-29: LGTM! Clean, typed auth endpoints.

All endpoints correctly use generic typing with ApiResponse<T> and consistently apply the unwrap helper. The module follows the established API layer pattern.

web/src/api/endpoints/messages.ts (1)

1-12: LGTM! Properly typed messages endpoints.

Both listMessages and listChannels correctly use the appropriate unwrap helpers with proper generic typing, consistent with the API layer patterns.

web/src/api/endpoints/company.ts (1)

1-17: LGTM! Response envelope types now properly preserved.

All apiClient.get() calls now include explicit generic types (ApiResponse<...> or PaginatedResponse<...>), addressing the previous review concern. URL encoding is correctly applied for the dynamic department name parameter.

web/src/stores/company.ts (1)

16-57: LGTM! Race condition properly addressed with generation guards.

The generation counter pattern (configGen, departmentsGen) correctly prevents stale responses from overwriting newer data. The guards are consistently applied across state updates, error handling, and loading flag resets.

web/src/api/endpoints/budget.ts (1)

1-7: LGTM! Properly typed budget endpoints.

getBudgetConfig and getAgentSpending correctly use generic typing and URL encoding for dynamic path segments.

Also applies to: 16-19

web/src/api/endpoints/providers.ts (1)

20-28: LGTM! Provider endpoints correctly sanitize secrets.

The getProvider function properly strips api_key before returning, and getProviderModels correctly returns model configurations without sensitive data.

web/src/stores/providers.ts (2)

7-19: LGTM! Prototype-pollution mitigation properly implemented.

The sanitizeProviders function now correctly:

  1. Uses Object.create(null) for a prototype-free accumulator
  2. Skips unsafe keys (__proto__, prototype, constructor) via the denylist
  3. Strips api_key from each provider

The static analysis warning at line 16 is a false positive given these mitigations.


21-40: LGTM! Clean provider store implementation.

The store correctly manages loading state, error handling, and applies sanitization to fetched provider data.

web/src/api/endpoints/agents.ts (1)

4-25: Good endpoint hygiene and response handling.

Consistent use of encodeURIComponent for dynamic path segments and shared unwrapping helpers looks correct.

web/src/utils/format.ts (1)

22-35: Relative-time future fallback is correctly handled.

Returning formatDate(iso) for future timestamps avoids misleading “just now” output.

web/src/stores/budget.ts (1)

49-59: fetchAgentSpending loading lifecycle is now consistent.

Setting/resetting loading in try/finally aligns this action with the rest of the store.

web/src/__tests__/stores/budget.test.ts (1)

130-141: Good negative-path WS test.

The invalid payload test correctly verifies no mutation of records and totalRecords.

web/src/api/endpoints/approvals.ts (1)

12-35: Approvals endpoint module is consistent with the API-layer pattern.

The request/unwrap flow and encoded path handling are implemented cleanly.

web/src/utils/constants.ts (1)

54-69: Nice addition of reusable nav typing.

Exporting NavItem from NAV_ITEMS improves type-safe reuse across layout/navigation components.

web/src/stores/auth.ts (1)

78-110: Good extraction of shared post-auth flow.

performAuthFlow centralizes token set, profile fetch, and failure handling cleanly.

web/src/composables/useAuth.ts (1)

6-25: Composable is clean and aligned with store-derived auth state.

The exposed computed helpers and canWrite check are straightforward and easy to consume in components.

web/src/router/index.ts (1)

5-34: Router setup and guard integration look good.

Routes, lazy imports, catch-all redirect, and authGuard hookup are consistent and clean.

web/src/main.ts (2)

34-37: Good hardening on unhandled rejections.

Calling event.preventDefault() before logging the sanitized reason closes the prior leakage path.


21-26: 🧹 Nitpick | 🔵 Trivial

Code quality improvement: Extract regex pattern to a named constant.

While the current regex patterns are valid and don't trigger linting errors, extracting /[\x00-\x1f\x7f]/g to a reusable constant improves maintainability and reduces duplication across lines 23 and 25.

Suggested refactoring
 /** Sanitize a value for safe logging (strip control chars, truncate). */
+const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/g
+
 function sanitizeForLog(value: unknown, maxLen = 500): string {
   if (value instanceof Error) {
-    return value.message.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, maxLen)
+    return value.message.replace(CONTROL_CHAR_RE, ' ').slice(0, maxLen)
   }
-  return String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, maxLen)
+  return String(value).replace(CONTROL_CHAR_RE, ' ').slice(0, maxLen)
 }
			> Likely an incorrect or invalid review comment.
web/src/components/common/StatusBadge.vue (1)

13-22: Color mapping implementation is solid.

Branching across status / priority / risk with fallback handling is clear and robust.

web/index.html (1)

10-12: Bootstrap markup looks good for SPA startup.

The #app loading fallback is present and will be replaced on mount as expected.

web/src/__tests__/stores/messages.test.ts (1)

23-63: WS behavior tests are aligned with store semantics.

Good coverage on message insertion/filtering and explicit assertion that total remains REST-derived.

web/src/components/layout/Sidebar.vue (1)

44-71: Sidebar interaction controls are in good shape.

type="button" is correctly set, route-active logic is safe, and toggle emit wiring is clean.

web/src/styles/theme.ts (1)

35-70: Strong typing for badge domains is a solid improvement.

Using Status, Priority, and RiskLevel unions with typed maps gives safer lookups and better IDE support.

web/src/stores/messages.ts (1)

1-78: Well-structured Pinia store with proper separation of concerns.

The implementation correctly:

  • Separates loading and error states for messages vs channels
  • Addresses the previous review feedback about total semantics (now clearly documented as server-side count only)
  • Caps WebSocket messages to prevent unbounded memory growth
  • Uses proper async/await patterns with error handling

Comment on lines +20 to 21
# Stage 2: Serve with nginx
FROM nginxinc/nginx-unprivileged:1.29.5-alpine@sha256:aec540f08f99df3c830549d5dd7bfaf63e01cbbb499e37400c5af9f8e8554e9f
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify nginxinc/nginx-unprivileged:1.29.5-alpine exists and check digest
# Expected: Tag exists and digest matches what's in Dockerfile

# Check if the image tag exists on Docker Hub
curl -s "https://hub.docker.com/v2/repositories/nginxinc/nginx-unprivileged/tags/1.29.5-alpine" | jq '{name: .name, last_updated: .last_updated, digest: .images[0].digest}'

Repository: Aureliolo/synthorg

Length of output: 227


Update Dockerfile to use current digest or investigate digest mismatch.

The tag 1.29.5-alpine exists on Docker Hub, but the pinned digest in the Dockerfile (sha256:aec540f08...) does not match the current digest for this tag on Docker Hub (sha256:981f19341c...). The tag was last updated on 2026-03-09. Either update the Dockerfile to use the current digest, or clarify if the older digest is intentionally pinned for compatibility reasons.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/web/Dockerfile` around lines 20 - 21, The FROM line using
nginxinc/nginx-unprivileged:1.29.5-alpine is pinned to an outdated digest
(sha256:aec540f08f99...), causing a mismatch; update the Dockerfile by either
replacing that digest with the current digest for the 1.29.5-alpine tag (e.g.,
the digest reported by Docker Hub) or remove the digest to rely on the tag if
you prefer floating updates, and document the choice; locate the offending
directive (the FROM nginxinc/nginx-unprivileged:1.29.5-alpine@sha256:... line)
and change it accordingly, ensuring CI/builds are re-run to verify the image
resolves.

Comment on lines +1 to +21
import pluginVue from 'eslint-plugin-vue'
import pluginSecurity from 'eslint-plugin-security'
import tsParser from '@typescript-eslint/parser'

export default [
...pluginVue.configs['flat/essential'],
pluginSecurity.configs.recommended,
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: {
parser: tsParser,
},
},
},
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
},
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and examine the actual eslint.config.js file
find . -name "eslint.config.js" -type f

Repository: Aureliolo/synthorg

Length of output: 84


🏁 Script executed:

# Check the current state of the file to see if it matches the snippet
cat -n web/eslint.config.js | head -50

Repository: Aureliolo/synthorg

Length of output: 1035


🌐 Web query:

Vue ESLint flat config official examples TypeScript setup

💡 Result:

Official Vue flat-config + TypeScript examples

1) eslint-plugin-vue (official user guide) + typescript-eslint (and optional Prettier)

Example eslint.config.ts/js (flat config) from the Vue ESLint plugin docs: it composes @eslint/js, typescript-eslint flat configs, and eslint-plugin-vue flat configs, and notes that Vue SFCs should keep vue-eslint-parser and put @typescript-eslint/parser under parserOptions.parser when needed. [1]

Key bits shown in the doc example:

  • extends: [ eslint.configs.recommended, ...typescriptEslint.configs.recommended, ...eslintPluginVue.configs['flat/recommended'] ]
  • files: ['**/*.{ts,vue}']
  • (Optional) add eslint-config-prettier to avoid conflicts with Prettier [1]

2) @vue/eslint-config-typescript (official Vue org repo) minimal flat-config setup

Vue’s official TS config package provides defineConfigWithVueTs and vueTsConfigs that apply TypeScript ESLint configs to both *.ts and *.vue files. Minimal example from the repo: [2]

// eslint.config.mjs
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'

export default defineConfigWithVueTs(
  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
)

Sources: [1] eslint-plugin-vue User Guide (flat config + TS example) • [2] vuejs/eslint-config-typescript (Minimal Setup)


🌐 Web query:

typescript-eslint shared configs flat config recommended

💡 Result:

Flat config + typescript-eslint “recommended” (no type info)

// eslint.config.mjs
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommended,
);

[1]

Flat config + “recommended” with type-aware rules

// eslint.config.mjs
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
      },
    },
  },
);

[2]

Optional: add stricter / stylistic presets (flat config exports)

typescript-eslint also ships strict, strictTypeChecked, stylistic, stylisticTypeChecked, plus utilities like disableTypeChecked and eslintRecommended (mostly auto-included when you use the recommended presets). [3]

Sources: [1] [2] [3]


Add the missing baseline JS/TS presets.

This config wires in TypeScript parsing but omits @eslint/js and typescript-eslint's shared configs, leaving the dashboard without standard baseline JS and TS rule coverage. The official Vue ESLint documentation and typescript-eslint guidance both recommend including eslint.configs.recommended and typescript-eslint.configs.recommended in flat configs. Additionally, consolidate the .vue and .ts file configs to use @typescript-eslint/parser directly rather than importing it separately.

Suggested config shape
+import js from '@eslint/js'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
 import pluginVue from 'eslint-plugin-vue'
 import pluginSecurity from 'eslint-plugin-security'
-import tsParser from '@typescript-eslint/parser'

 export default [
+  js.configs.recommended,
+  ...tseslint.configs.recommended,
   ...pluginVue.configs['flat/essential'],
   pluginSecurity.configs.recommended,
   {
-    files: ['**/*.vue'],
+    files: ['**/*.{ts,vue}'],
     languageOptions: {
+      globals: globals.browser,
+    },
+  },
+  {
+    files: ['**/*.vue'],
+    languageOptions: {
       parserOptions: {
-        parser: tsParser,
+        parser: tseslint.parser,
       },
     },
   },
-  {
-    files: ['**/*.ts'],
-    languageOptions: {
-      parser: tsParser,
-    },
-  },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/eslint.config.js` around lines 1 - 21, The config is missing baseline
JS/TS presets—add eslint's built-in recommended and typescript-eslint's
recommended configs to the exported array (reference eslint.configs.recommended
and typescript-eslint.configs.recommended) so standard JS/TS rules are applied;
also remove the separate tsParser import and instead configure the parser via
the typescript-eslint plugin in the per-file languageOptions for both '**/*.vue'
and '**/*.ts' entries (consolidate those entries so they use the same parser
configuration) while keeping pluginVue.configs['flat/essential'] and
pluginSecurity.configs.recommended in place.

Comment on lines +23 to +25
rules: {
'vue/no-v-html': 'warn',
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, verify the file exists and check the relevant section
cat -n web/eslint.config.js | head -40

Repository: Aureliolo/synthorg

Length of output: 1035


🏁 Script executed:

# Check if there's a CI configuration that enforces warnings
find . -name "*.yml" -o -name "*.yaml" | grep -E "(github|gitlab|circleci|travis|workflow)" | head -20

Repository: Aureliolo/synthorg

Length of output: 448


🏁 Script executed:

# Search for any inline comments or discussions about vue/no-v-html
rg "vue/no-v-html|v-html" --type js --type json -B 2 -A 2

Repository: Aureliolo/synthorg

Length of output: 226


🏁 Script executed:

# Check the CI workflow to see how linting is enforced
cat -n .github/workflows/ci.yml | head -100

Repository: Aureliolo/synthorg

Length of output: 3595


🏁 Script executed:

# Search for actual v-html usage in Vue files
rg "v-html" --type vue

Repository: Aureliolo/synthorg

Length of output: 89


🏁 Script executed:

# Check git history for this specific rule
git log --oneline -p web/eslint.config.js | grep -A 5 -B 5 "vue/no-v-html" | head -30

Repository: Aureliolo/synthorg

Length of output: 286


🏁 Script executed:

# Search for v-html usage without file type restriction
rg "v-html"

Repository: Aureliolo/synthorg

Length of output: 113


🏁 Script executed:

# Check if there's a package.json and npm scripts for eslint
find . -name "package.json" -type f -exec cat {} \;

Repository: Aureliolo/synthorg

Length of output: 1540


🏁 Script executed:

# Check for any npm/node build or lint scripts
rg "eslint" --type json --type js --type toml --type yaml

Repository: Aureliolo/synthorg

Length of output: 13233


Change vue/no-v-html from warn to error.

This rule reduces XSS risk from v-html. Setting it to warn weakens the security posture without clear justification. If exceptions are needed, use inline eslint-disable comments on specific lines rather than downgrading the rule globally.

Minimal fix
   {
     rules: {
-      'vue/no-v-html': 'warn',
+      'vue/no-v-html': 'error',
     },
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/eslint.config.js` around lines 23 - 25, Update the ESLint rules to
enforce 'vue/no-v-html' as an error instead of a warning by changing the rule
value in the rules object (the 'vue/no-v-html' entry in eslint.config.js) from
'warn' to 'error'; do not remove the rule—if specific exceptions are required,
document and apply inline eslint-disable comments at the offending v-html usages
rather than weakening the global rule.

Comment on lines +27 to +31
{
files: ['src/App.vue', 'src/components/layout/Sidebar.vue', 'src/components/layout/Topbar.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Prefer ignores over disabling the rule for whole files.

vue/multi-word-component-names already exempts the root App component, and the rule supports an ignores list for deliberate single-word names like Sidebar and Topbar. Turning the rule off for entire files is broader than needed and will also hide future single-word renames inside those files. (eslint.vuejs.org)

More targeted rule config
-  {
-    files: ['src/App.vue', 'src/components/layout/Sidebar.vue', 'src/components/layout/Topbar.vue'],
-    rules: {
-      'vue/multi-word-component-names': 'off',
-    },
-  },
+  {
+    rules: {
+      'vue/multi-word-component-names': ['error', { ignores: ['Sidebar', 'Topbar'] }],
+    },
+  },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
files: ['src/App.vue', 'src/components/layout/Sidebar.vue', 'src/components/layout/Topbar.vue'],
rules: {
'vue/multi-word-component-names': 'off',
},
{
files: ['src/App.vue', 'src/components/layout/Sidebar.vue', 'src/components/layout/Topbar.vue'],
rules: {
'vue/multi-word-component-names': ['error', { ignores: ['Sidebar', 'Topbar'] }],
},
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/eslint.config.js` around lines 27 - 31, Replace the per-file disabling of
'vue/multi-word-component-names' with a targeted ignores list: remove the files
block that turns the rule off for 'src/App.vue',
'src/components/layout/Sidebar.vue', and 'src/components/layout/Topbar.vue' and
instead add an ignores entry for the rule 'vue/multi-word-component-names'
listing 'Sidebar' and 'Topbar' (leave 'App' to rely on the rule's built-in
root-component exemption); update eslint.config.js to configure the rule via its
ignores option rather than turning it off for whole files.

Comment on lines +50 to +62
function handleWsEvent(event: WsEvent) {
if (event.event_type === 'message.sent') {
const message = event.payload as unknown as Message
if (message.id) {
// Only append and count if message matches active channel (or no filter is set)
if (!activeChannel.value || message.channel === activeChannel.value) {
messages.value = [...messages.value, message].slice(-MAX_WS_MESSAGES)
// Don't increment total — it tracks the server-side paginated count
// and is only set from the REST API response in fetchMessages()
}
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding basic runtime validation for the WebSocket payload.

The cast event.payload as unknown as Message assumes the payload structure matches the Message type. If the backend sends a malformed payload (e.g., during API version mismatch), this could cause silent failures or runtime errors when accessing message properties.

A minimal guard (e.g., checking typeof message.channel === 'string') would improve robustness against unexpected payloads.

🛡️ Optional: Add basic payload validation
   function handleWsEvent(event: WsEvent) {
     if (event.event_type === 'message.sent') {
       const message = event.payload as unknown as Message
-      if (message.id) {
+      if (message.id && typeof message.channel === 'string') {
         // Only append and count if message matches active channel (or no filter is set)
         if (!activeChannel.value || message.channel === activeChannel.value) {
           messages.value = [...messages.value, message].slice(-MAX_WS_MESSAGES)
           // Don't increment total — it tracks the server-side paginated count
           // and is only set from the REST API response in fetchMessages()
         }
       }
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function handleWsEvent(event: WsEvent) {
if (event.event_type === 'message.sent') {
const message = event.payload as unknown as Message
if (message.id) {
// Only append and count if message matches active channel (or no filter is set)
if (!activeChannel.value || message.channel === activeChannel.value) {
messages.value = [...messages.value, message].slice(-MAX_WS_MESSAGES)
// Don't increment total — it tracks the server-side paginated count
// and is only set from the REST API response in fetchMessages()
}
}
}
}
function handleWsEvent(event: WsEvent) {
if (event.event_type === 'message.sent') {
const message = event.payload as unknown as Message
if (message.id && typeof message.channel === 'string') {
// Only append and count if message matches active channel (or no filter is set)
if (!activeChannel.value || message.channel === activeChannel.value) {
messages.value = [...messages.value, message].slice(-MAX_WS_MESSAGES)
// Don't increment total — it tracks the server-side paginated count
// and is only set from the REST API response in fetchMessages()
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/messages.ts` around lines 50 - 62, The WebSocket handler
handleWsEvent currently blindly casts event.payload to Message which can throw
or corrupt state if payload is malformed; add a small runtime guard before using
fields: after const message = event.payload as unknown as Message, verify
required properties (e.g., typeof message?.id === 'string' || typeof message?.id
=== 'number', typeof message?.channel === 'string') and only then proceed to the
activeChannel/messaging logic (messages.value = [...messages.value,
message].slice(-MAX_WS_MESSAGES)); if validation fails, early-return or log and
ignore the payload so fetchMessages() remains the authoritative source for total
counts.

Comment on lines +57 to +105
async function createTask(data: CreateTaskRequest): Promise<Task | null> {
error.value = null
try {
const task = await tasksApi.createTask(data)
// Guard against race with WS task.created event
if (!tasks.value.some((t) => t.id === task.id)) {
tasks.value = [...tasks.value, task]
total.value++
}
return task
} catch (err) {
error.value = getErrorMessage(err)
return null
}
}

async function updateTask(taskId: string, data: UpdateTaskRequest): Promise<Task | null> {
error.value = null
try {
const updated = await tasksApi.updateTask(taskId, data)
tasks.value = tasks.value.map((t) => (t.id === taskId ? updated : t))
return updated
} catch (err) {
error.value = getErrorMessage(err)
return null
}
}

async function transitionTask(
taskId: string,
data: TransitionTaskRequest,
): Promise<Task | null> {
error.value = null
try {
const updated = await tasksApi.transitionTask(taskId, data)
tasks.value = tasks.value.map((t) => (t.id === taskId ? updated : t))
return updated
} catch (err) {
error.value = getErrorMessage(err)
return null
}
}

async function cancelTask(taskId: string, data: CancelTaskRequest): Promise<Task | null> {
error.value = null
try {
const updated = await tasksApi.cancelTask(taskId, data)
tasks.value = tasks.value.map((t) => (t.id === taskId ? updated : t))
return updated
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Filtered views drift after local task mutations.

createTask always appends, and Lines 76-104 always replace in place even when currentFilters are active. A status: 'created' view will still show a task after transitionTask() or cancelTask(), and filtered or paginated lists can append newly created records that should not be visible. The task.created WS path on Lines 117-120 already treats filtered views as REST-synced only; these local mutations need the same guard or a post-mutation refetch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/tasks.ts` around lines 57 - 105, Local mutations in
createTask, updateTask, transitionTask, and cancelTask blindly append/replace
tasks.value causing filtered or paginated views to drift; change each function
to either (a) check the currentFilters (or a helper like matchesFilters) and
only insert/replace the task in tasks.value when the returned task satisfies
currentFilters, or (b) if currentFilters are active, perform a post-mutation
refetch of the current REST page instead of mutating tasks.value directly; apply
this guard to createTask (avoid appending created tasks that don't match
filters) and to updateTask/transitionTask/cancelTask (only replace in-place when
the updated task still matches currentFilters, otherwise remove it or refetch).

Comment on lines +7 to +8
function sanitizeLogValue(value: unknown, max = 200): string {
return String(value).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, max)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find and examine the websocket.ts file
find . -path "*/web/src/stores/websocket.ts" -type f

Repository: Aureliolo/synthorg

Length of output: 91


🏁 Script executed:

# Check for Biome configuration
find . -name "biome.json" -o -name "biome.jsonc" -o -name ".biomeignore" | head -20

Repository: Aureliolo/synthorg

Length of output: 74


🏁 Script executed:

# Examine the websocket.ts file around lines 7-8
if [ -f "web/src/stores/websocket.ts" ]; then
  head -15 web/src/stores/websocket.ts | cat -n
fi

Repository: Aureliolo/synthorg

Length of output: 918


🏁 Script executed:

# Check Biome configuration
cat biome.json

Repository: Aureliolo/synthorg

Length of output: 1844


🏁 Script executed:

# Search for any lint results or CI logs that might show if this is actually blocking
fd -e "log" -e "report" | grep -i "lint\|biome" | head -20

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Check if there are any disable comments or rule configurations in websocket.ts
cat web/src/stores/websocket.ts | head -50 | cat -n

Repository: Aureliolo/synthorg

Length of output: 2581


🏁 Script executed:

# Check if there are any biome suppress/disable comments in the websocket.ts file
grep -n "biome" web/src/stores/websocket.ts

Repository: Aureliolo/synthorg

Length of output: 44


🏁 Script executed:

# Try to run Biome linting on the websocket.ts file to see actual linting results
if command -v biome &> /dev/null; then
  biome lint web/src/stores/websocket.ts 2>&1 | head -100
else
  echo "Biome not available in this environment"
fi

Repository: Aureliolo/synthorg

Length of output: 3994


🏁 Script executed:

# Check Biome documentation for the noControlCharactersInRegex rule in the default recommended set
# Search for any discussion or configuration related to this rule in the codebase
rg -i "noControlCharactersInRegex|nocontrolcharactersinregex" --type json --type ts --type js

Repository: Aureliolo/synthorg

Length of output: 44


This sanitizer is currently blocking CI.

Biome lint/suspicious/noControlCharactersInRegex errors on Line 8 with the regex /[\x00-\x1f\x7f]/g. Replace the control-character escapes in the regex pattern with a code-point-based sanitizer or another approach that avoids literal control characters in the regular expression.

🧰 Tools
🪛 Biome (2.4.6)

[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)


[error] 8-8: Unexpected control character in a regular expression.

(lint/suspicious/noControlCharactersInRegex)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/stores/websocket.ts` around lines 7 - 8, The regex in
sanitizeLogValue uses control-character escapes that trigger the linter; replace
the character-class /[\x00-\x1f\x7f]/g with a Unicode property class to match
control characters instead (e.g. use /\p{Cc}/gu) so the pattern avoids literal
control escapes and add the 'u' flag; keep the rest of sanitizeLogValue
(String(value).replace(...).slice(0, max)) intact.

Comment on lines +60 to +68
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (mins > 0 || parts.length === 0) parts.push(`${mins}m`)
return parts.join(' ')
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard invalid uptime inputs before formatting.

formatUptime currently accepts negative/non-finite values, which can render invalid durations.

Suggested fix
 export function formatUptime(seconds: number): string {
-  const days = Math.floor(seconds / 86400)
-  const hours = Math.floor((seconds % 86400) / 3600)
-  const mins = Math.floor((seconds % 3600) / 60)
+  if (!Number.isFinite(seconds) || seconds < 0) return '0m'
+  const safe = Math.floor(seconds)
+  const days = Math.floor(safe / 86400)
+  const hours = Math.floor((safe % 86400) / 3600)
+  const mins = Math.floor((safe % 3600) / 60)
   const parts: string[] = []
   if (days > 0) parts.push(`${days}d`)
   if (hours > 0) parts.push(`${hours}h`)
   if (mins > 0 || parts.length === 0) parts.push(`${mins}m`)
   return parts.join(' ')
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (mins > 0 || parts.length === 0) parts.push(`${mins}m`)
return parts.join(' ')
export function formatUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0m'
const safe = Math.floor(seconds)
const days = Math.floor(safe / 86400)
const hours = Math.floor((safe % 86400) / 3600)
const mins = Math.floor((safe % 3600) / 60)
const parts: string[] = []
if (days > 0) parts.push(`${days}d`)
if (hours > 0) parts.push(`${hours}h`)
if (mins > 0 || parts.length === 0) parts.push(`${mins}m`)
return parts.join(' ')
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/utils/format.ts` around lines 60 - 68, formatUptime currently accepts
negative or non-finite seconds and may produce invalid output; update the
function (formatUptime) to validate the input by checking
Number.isFinite(seconds) and seconds >= 0, and if the check fails either clamp
seconds to 0 or return a normalized string like "0m" (instead of proceeding),
then continue computing days/hours/mins from the validated value so
negative/NaN/Infinity inputs cannot produce bad durations.

Comment on lines +18 to +33
const attempts = ref(0)
const lockedUntil = ref<number | null>(null)

// Reactive clock so `locked` re-evaluates when lockout expires
const now = ref(Date.now())
const clockTimer = setInterval(() => { now.value = Date.now() }, 1000)
onUnmounted(() => clearInterval(clockTimer))

const locked = computed(() => !!(lockedUntil.value && now.value < lockedUntil.value))

function checkAndClearLockout(): boolean {
if (lockedUntil.value && Date.now() >= lockedUntil.value) {
lockedUntil.value = null
attempts.value = 0
}
return locked.value
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Back this lockout with a server-side limit.

Lines 18-19 keep the lockout entirely in component state, so a refresh or direct API request clears or bypasses it immediately. Keep the UI feedback if you want it, but the real attempt limit still needs to be enforced on the backend.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/views/LoginPage.vue` around lines 18 - 33, The client-side lockout
(attempts, lockedUntil, now, locked, checkAndClearLockout, clockTimer) is only
UI-level and can be bypassed—update LoginPage.vue to rely on server-enforced
lockout: on mount fetch the account/session lock status and attempt count from
an auth endpoint and populate lockedUntil/attempts, include these values in
every login request and handle server responses that indicate lock or remaining
attempts, and on login attempts always use the server response to update
lockedUntil/attempts (keeping the reactive UI clock only for display); ensure
the login API enforces the true limit and returns clear lock expiration and
attempt data for the client to consume.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 13, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.82%. Comparing base (fa8bf1d) to head (bee3786).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #342      +/-   ##
==========================================
+ Coverage   93.75%   93.82%   +0.07%     
==========================================
  Files         433      441       +8     
  Lines       19831    20243     +412     
  Branches     1911     1950      +39     
==========================================
+ Hits        18592    18993     +401     
- Misses        957      966       +9     
- Partials      282      284       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…on, JSDoc clarity

- Remove overly-broad ws:/wss: from CSP connect-src; 'self' covers
  same-origin WebSocket in modern browsers (Chrome 73+, Firefox 45+)
- Remove redundant router.push('/login') in Topbar logout — clearAuth()
  already navigates, so the explicit push fired guards twice
- Update useOptimisticUpdate JSDoc to document the pending-guard null
  return case (no error set) vs server-error null return (error set)
Copilot AI review requested due to automatic review settings March 13, 2026 09:27
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview March 13, 2026 09:27 — with GitHub Actions Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/nginx.conf`:
- Around line 32-45: Update the misleading comment for the location block to
note nginx chooses the longest prefix match (so /api/v1/ws will match before
/api/ regardless of declaration order) and, if you want the idiomatic Connection
handling for WebSockets, add a map for $http_upgrade named $connection_upgrade
and change proxy_set_header Connection to use $connection_upgrade (referencing
the location /api/v1/ws and the proxy_set_header Connection directive).

In `@web/src/components/layout/Topbar.vue`:
- Line 17: userMenu is declared as an untyped template ref which can lead to
unsafe dereferences; change its declaration to a typed ref (e.g.,
ref<HTMLElement | null>(null) or ref<HTMLElement | null>()) and update all
usages (in setup and template code that reference userMenu.value, including the
occurrences around lines 39-40) to guard against null (if (userMenu.value) { ...
}) or use optional chaining (userMenu.value?.focus()) before accessing DOM
methods/properties so you don't assume initialization timing or any typing.
- Around line 60-66: The user-menu button can be unlabeled when
auth.user?.username is falsy; update the button in Topbar.vue (the element using
:label="auth.user?.username ?? ''" and `@click`="toggleUserMenu") to provide an
accessible name when username is empty by adding an aria-label (or binding a
computed like usernameLabel) that falls back to a clear string such as "User
menu" or "Open user menu"; ensure the aria-label only applies when no visible
label exists so screen readers receive a proper name without duplicating the
visible username.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 94baea1f-2a9e-4ffd-aba3-81999c1c5302

📥 Commits

Reviewing files that changed from the base of the PR and between 7253116 and 2319dc2.

📒 Files selected for processing (3)
  • web/nginx.conf
  • web/src/components/layout/Topbar.vue
  • web/src/composables/useOptimisticUpdate.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: Agent
  • GitHub Check: Greptile Review
  • GitHub Check: Test (Python 3.14)
  • GitHub Check: Analyze (python)
🧰 Additional context used
🧬 Code graph analysis (1)
web/src/composables/useOptimisticUpdate.ts (1)
web/src/utils/errors.ts (1)
  • getErrorMessage (16-66)
🔇 Additional comments (4)
web/src/composables/useOptimisticUpdate.ts (1)

1-50: LGTM — Clean implementation with proper edge-case handling.

The composable correctly addresses all previously raised concerns:

  • Re-entrancy is guarded at line 26.
  • Error logging is sanitized via getErrorMessage() at lines 39 and 42.
  • The JSDoc clearly documents the null-return semantics for callers to distinguish server errors from no-op (in-flight) cases.
  • The rollback initialization to null (line 29) elegantly handles the edge case where applyOptimistic() itself throws — no rollback is needed since the optimistic state was never applied.
web/nginx.conf (2)

22-25: LGTM! CSP and HSTS headers are well-configured.

The connect-src 'self' properly restricts WebSocket connections to same-origin (addressing the previously flagged overly-permissive ws:/wss: scheme sources). The browser compatibility note is helpful. HSTS with 2-year max-age and preload directive follows best practices.

Note: HSTS is only honored by browsers over HTTPS. If nginx runs behind a TLS-terminating load balancer (typical for port 8080), this works correctly.


47-57: LGTM!

API proxy configuration with explicit timeouts (5s connect, 30s read/send) is appropriate for REST endpoints. Proxy headers are correctly configured for downstream service visibility.

web/src/components/layout/Topbar.vue (1)

28-35: The async behavior concern in the logout command is unfounded. Both disconnect() (in web/src/stores/websocket.ts) and logout() (in web/src/stores/auth.ts) are synchronous functions. The try/catch block correctly handles synchronous errors from either operation.

			> Likely an incorrect or invalid review comment.

Comment on lines +32 to 45
# WebSocket proxy — MUST be before generic /api/ block
location /api/v1/ws {
proxy_pass http://backend:8000;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 3600s;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

WebSocket proxy configuration is functional but has a minor clarification.

The comment on line 32 states the WS block "MUST be before generic /api/ block", but nginx prefix location matching is by longest prefix, not declaration order—/api/v1/ws would match before /api/ regardless of position. The comment is harmless but slightly misleading.

The hardcoded Connection "upgrade" (line 37) works for a dedicated WebSocket endpoint. The idiomatic pattern uses a map for graceful fallback:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

This is optional since /api/v1/ws is explicitly for WebSocket traffic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/nginx.conf` around lines 32 - 45, Update the misleading comment for the
location block to note nginx chooses the longest prefix match (so /api/v1/ws
will match before /api/ regardless of declaration order) and, if you want the
idiomatic Connection handling for WebSockets, add a map for $http_upgrade named
$connection_upgrade and change proxy_set_header Connection to use
$connection_upgrade (referencing the location /api/v1/ws and the
proxy_set_header Connection directive).

const router = useRouter()
const auth = useAuthStore()
const wsStore = useWebSocketStore()
const userMenu = ref()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Type and guard the userMenu template ref to avoid unsafe dereference.

This currently relies on any and assumes ref initialization timing.

Suggested refactor
-const userMenu = ref()
+const userMenu = ref<{ toggle: (event: Event) => void } | null>(null)

 function toggleUserMenu(event: Event) {
-  userMenu.value.toggle(event)
+  userMenu.value?.toggle(event)
 }

Also applies to: 39-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/layout/Topbar.vue` at line 17, userMenu is declared as an
untyped template ref which can lead to unsafe dereferences; change its
declaration to a typed ref (e.g., ref<HTMLElement | null>(null) or
ref<HTMLElement | null>()) and update all usages (in setup and template code
that reference userMenu.value, including the occurrences around lines 39-40) to
guard against null (if (userMenu.value) { ... }) or use optional chaining
(userMenu.value?.focus()) before accessing DOM methods/properties so you don't
assume initialization timing or any typing.

Comment on lines +60 to +66
:label="auth.user?.username ?? ''"
icon="pi pi-user"
text
severity="secondary"
class="text-slate-300"
@click="toggleUserMenu"
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add an accessible name for the user-menu button when username is empty.

When auth.user?.username is missing, this becomes an unlabeled icon button for assistive tech.

Suggested fix
       <Button
         :label="auth.user?.username ?? ''"
+        :aria-label="auth.user?.username ? `User menu for ${auth.user.username}` : 'User menu'"
         icon="pi pi-user"
         text
         severity="secondary"
         class="text-slate-300"
         `@click`="toggleUserMenu"
       />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
:label="auth.user?.username ?? ''"
icon="pi pi-user"
text
severity="secondary"
class="text-slate-300"
@click="toggleUserMenu"
/>
<Button
:label="auth.user?.username ?? ''"
:aria-label="auth.user?.username ? `User menu for ${auth.user.username}` : 'User menu'"
icon="pi pi-user"
text
severity="secondary"
class="text-slate-300"
`@click`="toggleUserMenu"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/layout/Topbar.vue` around lines 60 - 66, The user-menu
button can be unlabeled when auth.user?.username is falsy; update the button in
Topbar.vue (the element using :label="auth.user?.username ?? ''" and
`@click`="toggleUserMenu") to provide an accessible name when username is empty by
adding an aria-label (or binding a computed like usernameLabel) that falls back
to a clear string such as "User menu" or "Open user menu"; ensure the aria-label
only applies when no visible label exists so screen readers receive a proper
name without duplicating the visible username.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Establishes the core Vue 3 + PrimeVue + Tailwind CSS web dashboard infrastructure (web/) plus supporting Docker/nginx and CI jobs, to serve as the base for the upcoming page/views PR.

Changes:

  • Adds the dashboard build tooling (Vite/Vitest, strict TS configs, ESLint) and app bootstrap (PrimeVue config, global styles, router).
  • Introduces typed API client + endpoint modules, Pinia stores, composables, and shared layout/common components.
  • Updates nginx/Docker/CI and docs to reflect the dashboard being built and to gate on dashboard lint/type-check/test/build/audit.

Reviewed changes

Copilot reviewed 84 out of 88 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
web/vitest.config.ts Adds Vitest config (jsdom, coverage).
web/vite.config.ts Adds Vite config (Vue/Tailwind, alias, dev proxy).
web/tsconfig.node.json TS config for tooling/build scripts.
web/tsconfig.json Strict TS config for app + vitest globals.
web/style.css Removes old placeholder CSS.
web/src/views/SetupPage.vue Adds initial admin setup view with lockout logic.
web/src/views/PlaceholderHome.vue Adds placeholder home route/view.
web/src/views/LoginPage.vue Adds login view with lockout and toast warning.
web/src/utils/format.ts Adds date/number/currency formatting utilities.
web/src/utils/errors.ts Adds Axios-aware, user-friendly error mapping.
web/src/utils/constants.ts Adds app-wide constants (WS, paging, nav, etc.).
web/src/styles/theme.ts Adds theme tokens + badge color maps.
web/src/styles/global.css Adds Tailwind v4 import + CSS variables + scrollbar styling.
web/src/stores/websocket.ts Adds WebSocket store (connect/reconnect/subscriptions/handlers).
web/src/stores/tasks.ts Adds tasks store with REST fetch + WS event handling.
web/src/stores/providers.ts Adds providers store with secret stripping/sanitization.
web/src/stores/messages.ts Adds messages store with REST fetch + WS appends.
web/src/stores/company.ts Adds company store with stale-fetch protection.
web/src/stores/budget.ts Adds budget store with REST + WS cost-record ingestion.
web/src/stores/auth.ts Adds auth store (token lifecycle, login/setup, user fetch).
web/src/stores/approvals.ts Adds approvals store with REST + WS event handling.
web/src/stores/analytics.ts Adds analytics store with stale-fetch protection.
web/src/stores/agents.ts Adds agents store with REST + WS event handling.
web/src/router/index.ts Adds router with placeholder/home + auth routes.
web/src/router/guards.ts Adds auth guard based on meta.requiresAuth.
web/src/primevue-preset.ts Adds PrimeVue Aura preset configuration.
web/src/main.ts Adds Vue app bootstrap + global error/rejection logging.
web/src/composables/usePolling.ts Adds non-overlapping polling composable.
web/src/composables/useOptimisticUpdate.ts Adds optimistic update helper with rollback.
web/src/composables/useAuth.ts Adds auth convenience composable (role checks).
web/src/components/layout/Topbar.vue Adds top bar with user menu and logout flow.
web/src/components/layout/Sidebar.vue Adds sidebar nav with collapsed mode + a11y labels.
web/src/components/layout/ConnectionStatus.vue Adds API/WS status indicator with polling.
web/src/components/layout/AppShell.vue Adds app shell layout composing sidebar/topbar.
web/src/components/common/StatusBadge.vue Adds status/priority/risk badge component.
web/src/components/common/PageHeader.vue Adds common page header component.
web/src/components/common/LoadingSkeleton.vue Adds skeleton loader component.
web/src/components/common/ErrorBoundary.vue Adds error boundary component.
web/src/components/common/EmptyState.vue Adds empty state component.
web/src/api/types.ts Adds TS DTO/envelope/types mirroring backend.
web/src/api/endpoints/tasks.ts Adds tasks endpoint module.
web/src/api/endpoints/providers.ts Adds providers endpoint module (api_key stripping).
web/src/api/endpoints/messages.ts Adds messages endpoint module.
web/src/api/endpoints/health.ts Adds health endpoint module.
web/src/api/endpoints/company.ts Adds company endpoint module.
web/src/api/endpoints/budget.ts Adds budget endpoint module.
web/src/api/endpoints/auth.ts Adds auth endpoint module.
web/src/api/endpoints/approvals.ts Adds approvals endpoint module.
web/src/api/endpoints/analytics.ts Adds analytics endpoint module.
web/src/api/endpoints/agents.ts Adds agents endpoint module.
web/src/api/client.ts Adds Axios client (JWT attach, 401 handling, unwrap helpers).
web/src/tests/utils/format.test.ts Adds unit tests for formatting utilities.
web/src/tests/utils/errors.test.ts Adds unit tests for error helpers.
web/src/tests/utils/constants.test.ts Adds unit tests for constants.
web/src/tests/stores/websocket.test.ts Adds unit tests for websocket store behavior.
web/src/tests/stores/tasks.test.ts Adds unit tests for tasks store behavior.
web/src/tests/stores/messages.test.ts Adds unit tests for messages store behavior.
web/src/tests/stores/budget.test.ts Adds unit tests for budget store behavior.
web/src/tests/stores/auth.test.ts Adds unit tests for auth store behavior.
web/src/tests/stores/approvals.test.ts Adds unit tests for approvals store behavior.
web/src/tests/stores/agents.test.ts Adds unit tests for agents store behavior.
web/src/tests/router/guards.test.ts Adds unit tests for auth guard behavior.
web/src/tests/composables/usePolling.test.ts Adds unit tests for polling composable.
web/src/tests/composables/useOptimisticUpdate.test.ts Adds unit tests for optimistic update composable.
web/src/tests/components/StatusBadge.test.ts Adds unit tests for StatusBadge.
web/src/tests/components/PageHeader.test.ts Adds unit tests for PageHeader.
web/src/tests/components/EmptyState.test.ts Adds unit tests for EmptyState.
web/src/tests/api/client.test.ts Adds unit tests for client unwrap + interceptor behavior.
web/src/App.vue Adds app root with Toast/ConfirmDialog and RouterView.
web/public/favicon.svg Adds dashboard favicon.
web/package.json Adds dashboard dependencies and scripts.
web/nginx.conf Updates nginx for SPA routing + API/WS proxy + CSP updates.
web/index.html Replaces placeholder HTML with Vite app entry + meta.
web/eslint.config.js Adds ESLint flat config (Vue + security plugin).
web/env.d.ts Adds Vite env typing and Vue module shim.
web/app.js Removes old placeholder health-check script.
web/.env.example Adds dashboard env example.
docs/user_guide.md Updates user guide wording about dashboard availability.
docs/roadmap/index.md Moves dashboard into “In Progress” section.
docs/getting_started.md Documents web dashboard dev commands.
docs/design/operations.md Updates ops design docs to reflect in-progress web UI.
docker/web/Dockerfile Switches to multi-stage build for dashboard → nginx runtime.
README.md Updates status blurb for dashboard progress.
CLAUDE.md Documents dashboard structure and common commands.
.gitignore Ignores dashboard coverage and generated artifacts.
.github/workflows/ci.yml Adds dashboard lint/type-check/test/build/audit CI jobs.
.dockerignore Ignores dashboard build artifacts and env for Docker context.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +10 to +17
export async function listProviders(): Promise<Record<string, ProviderConfig>> {
const response = await apiClient.get<ApiResponse<Record<string, ProviderConfig & { api_key?: unknown }>>>('/providers')
const raw = unwrap<Record<string, ProviderConfig & { api_key?: unknown }>>(response)
const result: Record<string, ProviderConfig> = {}
for (const [key, provider] of Object.entries(raw)) {
result[key] = stripSecrets(provider)
}
return result
Comment on lines +20 to +23
Container configuration (ports, storage paths, log level) is defined in `docker/.env`. Organization setup is done via the dashboard. Template configuration through the UI is planned for a future release.

!!! danger "Work in Progress"
SynthOrg is under active development. The web dashboard, templates, and many features described here are **not yet available**. Check the [GitHub repository](https://github.com/Aureliolo/synthorg) for current status.
!!! info "Active Development"
SynthOrg is under active development. The web dashboard is available for monitoring and managing the organization. Templates and some features described here may evolve. Check the [GitHub repository](https://github.com/Aureliolo/synthorg) for current status.
Comment on lines +104 to +108
/** Discriminated paginated response envelope. */
export type PaginatedResponse<T> =
| { data: T[]; error: null; success: true; pagination: PaginationMeta }
| { data: null; error: string; success: false; pagination: null }

Comment on lines +30 to +45
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError<{ error?: string; success?: boolean }>) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
localStorage.removeItem('auth_token_expires_at')
// Use router import for SPA-friendly navigation (preserves in-memory state)
if (window.location.pathname !== '/login' && window.location.pathname !== '/setup') {
// Dynamic import to avoid circular dependency with router -> stores -> api
import('@/router').then(({ router }) => {
router.push('/login')
}).catch(() => {
window.location.href = '/login'
})
}
}
Comment on lines +23 to +24
# connect-src 'self' covers same-origin ws:/wss: in modern browsers (Chrome 73+, Firefox 45+).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'" always;
- Reset reconnectAttempts in disconnect() to prevent stale budget
- Guard setToken against non-positive expiresIn
- Remove HSTS preload (should be opt-in for self-hosted deployments)
- Normalize error in changePassword instead of misleading re-throw
Comment on lines +55 to +60
} else {
error.value = getErrorMessage(err)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Login does not navigate after redirect param

After a successful login, the code unconditionally pushes to '/'. However, the auth guard preserves the intended destination in the redirect query parameter (e.g., ?redirect=%2Ftasks%2F123). If a user is redirected to /login because they followed a deep link while unauthenticated, they will always land on the home page instead of their original destination.

SetupPage.vue (line 60) has the same issue.

Suggested change
} else {
error.value = getErrorMessage(err)
}
}
}
const redirect = router.currentRoute.value.query.redirect
if (typeof redirect === 'string' && redirect.startsWith('/')) {
router.push(redirect)
} else {
router.push('/')
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/views/LoginPage.vue
Line: 55-60

Comment:
**Login does not navigate after redirect param**

After a successful login, the code unconditionally pushes to `'/'`. However, the auth guard preserves the intended destination in the `redirect` query parameter (e.g., `?redirect=%2Ftasks%2F123`). If a user is redirected to `/login` because they followed a deep link while unauthenticated, they will always land on the home page instead of their original destination.

`SetupPage.vue` (line 60) has the same issue.

```suggestion
    const redirect = router.currentRoute.value.query.redirect
    if (typeof redirect === 'string' && redirect.startsWith('/')) {
      router.push(redirect)
    } else {
      router.push('/')
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +43 to +57
await auth.login(username.value, password.value)
attempts.value = 0
if (auth.mustChangePassword) {
toast.add({ severity: 'warn', summary: 'Password change required', life: 5000 })
}
router.push('/')
} catch (err) {
attempts.value++
if (attempts.value >= LOGIN_MAX_ATTEMPTS) {
lockedUntil.value = Date.now() + LOGIN_LOCKOUT_MS
attempts.value = 0
error.value = `Too many failed attempts. Please wait ${LOGIN_LOCKOUT_MS / 1000} seconds.`
} else {
error.value = getErrorMessage(err)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lockout counter increments on network/5xx errors

attempts is incremented for every thrown error, including transient network failures (ERR_NETWORK) and 503 responses. A user on a flaky connection can be locked out after five timeouts even without a single wrong password. Only 401 responses from the server should count towards the lockout counter.

Suggested change
await auth.login(username.value, password.value)
attempts.value = 0
if (auth.mustChangePassword) {
toast.add({ severity: 'warn', summary: 'Password change required', life: 5000 })
}
router.push('/')
} catch (err) {
attempts.value++
if (attempts.value >= LOGIN_MAX_ATTEMPTS) {
lockedUntil.value = Date.now() + LOGIN_LOCKOUT_MS
attempts.value = 0
error.value = `Too many failed attempts. Please wait ${LOGIN_LOCKOUT_MS / 1000} seconds.`
} else {
error.value = getErrorMessage(err)
}
} catch (err) {
const status = (err as { response?: { status?: number } }).response?.status
if (status === 401) {
attempts.value++
if (attempts.value >= LOGIN_MAX_ATTEMPTS) {
lockedUntil.value = Date.now() + LOGIN_LOCKOUT_MS
attempts.value = 0
error.value = `Too many failed attempts. Please wait ${LOGIN_LOCKOUT_MS / 1000} seconds.`
} else {
error.value = getErrorMessage(err)
}
} else {
error.value = getErrorMessage(err)
}
}

The same pattern exists in SetupPage.vue around line 57.

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/views/LoginPage.vue
Line: 43-57

Comment:
**Lockout counter increments on network/5xx errors**

`attempts` is incremented for every thrown error, including transient network failures (`ERR_NETWORK`) and 503 responses. A user on a flaky connection can be locked out after five timeouts even without a single wrong password. Only 401 responses from the server should count towards the lockout counter.

```suggestion
  } catch (err) {
    const status = (err as { response?: { status?: number } }).response?.status
    if (status === 401) {
      attempts.value++
      if (attempts.value >= LOGIN_MAX_ATTEMPTS) {
        lockedUntil.value = Date.now() + LOGIN_LOCKOUT_MS
        attempts.value = 0
        error.value = `Too many failed attempts. Please wait ${LOGIN_LOCKOUT_MS / 1000} seconds.`
      } else {
        error.value = getErrorMessage(err)
      }
    } else {
      error.value = getErrorMessage(err)
    }
  }
```

The same pattern exists in `SetupPage.vue` around line 57.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +49 to +55
total.value = result.total
} catch (err) {
error.value = getErrorMessage(err)
} finally {
loading.value = false
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasActiveFilters treats false and 0 as inactive

The check v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '') will treat boolean false and numeric 0 as valid, non-empty filter values — but it misses them when they evaluate to falsy in a stricter test. More importantly, the comment on the WS handler says "only append if no active filters" to keep filtered views accurate, yet false/0 filter values will not be caught here and tasks will be appended to a filtered view.

A simpler and more robust check:

Suggested change
total.value = result.total
} catch (err) {
error.value = getErrorMessage(err)
} finally {
loading.value = false
}
}
function hasActiveFilters(): boolean {
return Object.values(currentFilters.value).some(
(v) => v !== undefined && v !== null && v !== '' && !(typeof v === 'string' && v.trim() === ''),
)
}

Or even simpler, treat any filter value that is not undefined/null/empty-string as "active", including false and 0 since those are meaningful API filter parameters.

Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/stores/tasks.ts
Line: 49-55

Comment:
**`hasActiveFilters` treats `false` and `0` as inactive**

The check `v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '')` will treat boolean `false` and numeric `0` as valid, non-empty filter values — but it misses them when they evaluate to falsy in a stricter test. More importantly, the comment on the WS handler says "only append if no active filters" to keep filtered views accurate, yet `false`/`0` filter values will not be caught here and tasks will be appended to a filtered view.

A simpler and more robust check:

```suggestion
  function hasActiveFilters(): boolean {
    return Object.values(currentFilters.value).some(
      (v) => v !== undefined && v !== null && v !== '' && !(typeof v === 'string' && v.trim() === ''),
    )
  }
```

Or even simpler, treat any filter value that is not `undefined`/`null`/empty-string as "active", including `false` and `0` since those are meaningful API filter parameters.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +56 to +62
if (!body.success || body.data === null || body.data === undefined) {
throw new Error(body.error ?? 'Unknown API error')
}
return body.data
}

/**
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unwrap rejects a success: true response with data: null

The condition body.data === null || body.data === undefined will throw even when the server intentionally returns { success: true, data: null } — a valid API contract for endpoints that return no payload (e.g., a DELETE that returns 200 with data: null). This would cause every such endpoint to throw "Unknown API error" despite the call succeeding.

Consider separating the error-success check from the null-data check:

Suggested change
if (!body.success || body.data === null || body.data === undefined) {
throw new Error(body.error ?? 'Unknown API error')
}
return body.data
}
/**
export function unwrap<T>(response: AxiosResponse<ApiResponse<T>>): T {
const body = response.data
if (!body.success) {
throw new Error(body.error ?? 'Unknown API error')
}
return body.data as T
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/api/client.ts
Line: 56-62

Comment:
**`unwrap` rejects a `success: true` response with `data: null`**

The condition `body.data === null || body.data === undefined` will throw even when the server intentionally returns `{ success: true, data: null }` — a valid API contract for endpoints that return no payload (e.g., a DELETE that returns 200 with `data: null`). This would cause every such endpoint to throw `"Unknown API error"` despite the call succeeding.

Consider separating the error-success check from the null-data check:

```suggestion
export function unwrap<T>(response: AxiosResponse<ApiResponse<T>>): T {
  const body = response.data
  if (!body.success) {
    throw new Error(body.error ?? 'Unknown API error')
  }
  return body.data as T
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +86 to +91
) {
loading.value = true
try {
const result = await authFn()
setToken(result.token, result.expires_in)
try {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performAuthFlow can enter a state where user.value is truthy but token is cleared

Inside the double-check block (if (!user.value)), clearAuth() nulls both token and user, then throw propagates to the caller. The caller in login or setup will see an exception but isAuthenticated will be false — that part is correct. However, there is a subtle race: fetchUser() may have successfully set user.value before a 401 inside fetchUser cleared auth (via clearAuth() in fetchUser's catch block). After that clearAuth(), user.value is null, so the outer if (!user.value) fires and calls clearAuth() a second time, which also calls router.push('/login') a second time. This double-push is benign in most cases but can leave duplicate history entries.

Consider checking isAuthenticated.value rather than user.value for the guard, since clearAuth is the authoritative reset:

Suggested change
) {
loading.value = true
try {
const result = await authFn()
setToken(result.token, result.expires_in)
try {
if (!isAuthenticated.value) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/stores/auth.ts
Line: 86-91

Comment:
**`performAuthFlow` can enter a state where `user.value` is truthy but token is cleared**

Inside the double-check block (`if (!user.value)`), `clearAuth()` nulls both `token` and `user`, then `throw` propagates to the caller. The caller in `login` or `setup` will see an exception but `isAuthenticated` will be `false` — that part is correct. However, there is a subtle race: `fetchUser()` may have successfully set `user.value` before a 401 inside `fetchUser` cleared auth (via `clearAuth()` in `fetchUser`'s catch block). After that `clearAuth()`, `user.value` is `null`, so the outer `if (!user.value)` fires and calls `clearAuth()` a second time, which also calls `router.push('/login')` a second time. This double-push is benign in most cases but can leave duplicate history entries.

Consider checking `isAuthenticated.value` rather than `user.value` for the guard, since `clearAuth` is the authoritative reset:

```suggestion
      if (!isAuthenticated.value) {
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants