feat: Workstation page — Products + Projects unified behind one sidebar entry (#549)

* feat(panel): Workstation page — Products and Projects as tabs behind one sidebar entry

* docs(map): workstation page, extracted views, and the local-filter scroll trade-off

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-18 00:45:21 +02:00
committed by GitHub
co-authored by Renn F
parent 496c24d186
commit c5dfbf6063
12 changed files with 386 additions and 275 deletions
+13 -5
View File
@@ -8020,7 +8020,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog to open a fresh CEO-owned 1:1, live via `/ws/system` `a2a.message` frames |
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
| `panel/src/app/(dashboard)/{agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
@@ -8031,7 +8032,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies) |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups; `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` / `useCreateCeoConversation` / `useSendCeoMessage` mutations; `a2aLiveKeys` query-key namespace |
@@ -8058,6 +8059,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Feature Flags | `components/settings/feature-flags-card.tsx` | Toggles persisted to settings store; takes effect on next backend restart |
| Intake / MegaTask | `app/(dashboard)/prompter/page.tsx` + `components/prompter/*` | Live SSE chat with spawned Claude/Grok intake agent; single-project, product, or multi-project (`project_ids`) MegaTask → `propose_batch``confirm-batch` |
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| A2A Live (switchboard + reply) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into the thread as itself (task-linked conversations only) |
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` |
@@ -8120,7 +8123,8 @@ graph TD
Routes --> Prompter["prompter (single + MegaTask)"]
Routes --> A2ALive["a2a (switchboard + reply)"]
Routes --> Settings["settings + ai-providers"]
Routes --> Domain["agents/projects/products/business/journals/git/kb/auditor/work-sessions/notifications"]
Routes --> Workstation["workstation (Products + Projects tabs)"]
Routes --> Domain["agents/business/journals/git/kb/auditor/work-sessions/notifications"]
Overview --> Dash["components/dashboard/*"]
Dash --> Release["ReleaseProposalCard"]
Dash --> Playbook["PlaybookReviewQueue"]
@@ -8153,7 +8157,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply/direct composer + New DM)
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
── {agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here)
│ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
├── src/components/
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity)
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
@@ -8166,7 +8171,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ ├── projects/projects-view.tsx, products/products-view.tsx (Workstation tab panes)
│ ├── projects/ products/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
├── src/hooks/
│ ├── use-websocket.ts (shared useWebSocket<T>: auto-reconnect, heartbeat)
@@ -8238,6 +8244,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top**`WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
- **Workstation's `ProjectsView` filters (q/cell/inactive) are local `useState`, not URL params** — deliberate: any URL write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top. Trade-off disclosed: a filtered Projects view is no longer a shareable/bookmarkable link (only `?tab=` rides the URL). `/products` and `/projects` are now redirect shims to `/workstation?tab=...`, so old bookmarks/links still resolve.
## Drift from CLAUDE.md
- CLAUDE.md says panel lives at `roboco/panel/` inside this repo — confirmed (no longer a separate `roboco-panel` project). No drift.
@@ -8268,6 +8275,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO New-DM composer: `a2a-new-dm-dialog.tsx` (opens a fresh CEO-owned 1:1, `AgentSelector`'s new `excludeRoles` prop) + `a2a-direct-composer.tsx` (posts in a CEO-owned thread, no task link needed) wired into `page.tsx`'s composer-selection branch (CEO-owned thread → direct composer; task-linked watched thread → reply composer; else read-only). `use-a2a-live.ts` adds `useCreateCeoConversation`/`useSendCeoMessage`; `lib/api/a2a.ts` adds `createConversation`/`sendCeoMessage` (both force `X-Agent-ID: "ceo"` per-call). `client.ts`'s header injection changed from an unconditional overwrite to a `has()`/`set()` default so a per-call override survives. Backend: `A2AService._maybe_wake_ceo_recipient` wakes an offline `read_a2a`-capable recipient of a CEO DM via the `a2a_request` dispatch path — see `docs/map/a2a-audit-journal-permissions.md`. Same branch also scrubbed "message the CEO" recipes from `docs/rag`/`agents/prompts` (agents are never taught to DM the CEO — reply-only).
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Wave 2 hygiene + charts: `/work-sessions` route now redirects to `/git?tab=sessions` (moved under Git as a "Work Sessions" tab, `git-page.tsx` gains a `Tabs`); `GitActionsPanel` gains a confirm-gated "Clean Up Stale Branches" button (`useCleanupBranches`, cursor-resumable); `WorkSessionsView`'s filters moved from URL params to local state (ScrollRestoration bounce fix); new `SessionTrendChart` / `CostTrendChart` / `SpendTrendChart`.
> - `dd4cb7f1` Wave 4: Workstation page (PR #549, 2026-07-17) — Products + Projects merged into one `/workstation?tab=` page (Products first); content extracted byte-faithfully into `components/products/products-view.tsx` + `components/projects/projects-view.tsx`; `products/page.tsx` + `projects/page.tsx` become redirect shims; the two sidebar entries collapse into one "Workstation" entry (`Briefcase` icon); `task-metadata.tsx`'s project-card link retargets to `/workstation?tab=projects`; `ProjectsView`'s q/cell/inactive filters move from URL params to local `useState` (scroll-bounce prevention, trades away shareable filtered-view links).
## Regression Risks
+13 -5
View File
@@ -17,7 +17,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/app/(dashboard)/prompter/page.tsx` | Intake chat (single + MegaTask batch scope) |
| `panel/src/app/(dashboard)/a2a/page.tsx` | A2A Live: org-wide switchboard/list + transcript + CEO reply composer + "New DM" dialog to open a fresh CEO-owned 1:1, live via `/ws/system` `a2a.message` frames |
| `panel/src/app/(dashboard)/settings/page.tsx` + `settings/ai-providers/page.tsx` | Settings: feature flags, AI routing, transcript retention, self-hosted |
| `panel/src/app/(dashboard)/{agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(dashboard)/workstation/page.tsx` | Workstation: Products + Projects merged as URL-param tabs (`?tab=products\|projects`, Products first); `products/page.tsx` and `projects/page.tsx` are now server-component redirects to it |
| `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages |
| `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin``/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group |
| `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present |
| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` |
@@ -28,7 +29,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| `panel/src/components/tasks/` + `tasks/task-detail/` | task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector, task-detail tabs (overview/plan/progress/commits/sessions/notes/dependencies/**findings**) |
| `panel/src/components/settings/` | feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, `x-credentials-card.tsx` (write-only OAuth 1.0a secrets, mounted in `settings/page.tsx`) |
| `panel/src/components/conventions/conventions-tab.tsx` | Per-project architecture map + health (in edit-project dialog) |
| `panel/src/components/projects/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups; `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/components/projects/`, `products/`, `agents/`, `business/`, `auditor/`, `knowledge-base/`, `git/`, `journals/`, `work-sessions/`, `notifications/`, `rate-limit/`, `layout/`, `ui/` | Per-domain component groups (`projects/` and `products/` each export a `*-view.tsx` consumed by `workstation/page.tsx`); `ui/` = Radix-based primitives (dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown) |
| `panel/src/hooks/use-websocket.ts` | Shared `useWebSocket<T>(path, handlers?, isSystem?)` hook (auto-reconnect, heartbeat) |
| `panel/src/hooks/use-{tasks,agents,projects,products,usage,prompter,secretary,dashboard,git,journals,notifications,knowledge-base,observability,work-sessions,providers,rate-limit-{sync,websocket}}.ts` | TanStack Query + zustand data hooks |
| `panel/src/hooks/use-a2a-live.ts` | `useA2AConversations` / `useA2AAdminPairs` / `useA2AMessages` (TanStack Query over `a2aApi`) + `useReplyAsCeo` / `useCreateCeoConversation` / `useSendCeoMessage` mutations; `a2aLiveKeys` query-key namespace |
@@ -56,6 +57,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
| Feature Flags | `components/settings/feature-flags-card.tsx` | Toggles persisted to settings store; takes effect on next backend restart |
| Intake / MegaTask | `app/(dashboard)/prompter/page.tsx` + `components/prompter/*` | Live SSE chat with spawned Claude/Grok intake agent; single-project, product, or multi-project (`project_ids`) MegaTask → `propose_batch``confirm-batch` |
| A2A Live (switchboard + reply + New DM) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into a watched thread as itself (task-linked conversations only). "New DM" opens a fresh CEO-owned 1:1 with any DM-capable agent (no task link needed); the recipient is woken via the a2a_request dispatch path if offline, and the CEO's own threads render with `A2ADirectComposer` instead of the reply composer |
| A2A Live (switchboard + reply) | `app/(dashboard)/a2a/page.tsx` + `components/a2a/*` | CEO watches every agent-to-agent conversation live: default org-chart switchboard (pair cards grouped by cell/PM-chain/board, pulsing on fresh `a2a.message` frames) or the classic conversation list; drill-in shows the transcript + a reply composer that lets the CEO chime into the thread as itself (task-linked conversations only) |
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; Projects' q/cell/inactive filters are local `useState`, not URL params (scroll-bounce prevention) |
| Project Settings / Conventions | `components/projects/edit-project-dialog.tsx` + `components/conventions/conventions-tab.tsx` | Per-project `.roboco/conventions.yml` map + health; Save / Restore via PR |
| Usage Dashboard | `components/dashboard/usage-overview-panel.tsx` + `hooks/use-usage.ts` | Token/cost totals; live WS snapshot with HTTP-polling fallback |
| Git | `app/(dashboard)/git/page.tsx` | Repository / Work Sessions tabs (business-page tab idiom, `?tab=`); `GitBrowser` (status/branches/log/diff + actions incl. confirm-gated "Clean Up Stale Branches") and `WorkSessionsView` (active sessions, search/status filter kept LOCAL not in URL params); old `/work-sessions` route now redirects to `/git?tab=sessions` |
@@ -118,7 +121,8 @@ graph TD
Routes --> Prompter["prompter (single + MegaTask)"]
Routes --> A2ALive["a2a (switchboard + reply)"]
Routes --> Settings["settings + ai-providers"]
Routes --> Domain["agents/projects/products/business/journals/git/kb/auditor/work-sessions/notifications"]
Routes --> Workstation["workstation (Products + Projects tabs)"]
Routes --> Domain["agents/business/journals/git/kb/auditor/work-sessions/notifications"]
Overview --> Dash["components/dashboard/*"]
Dash --> Release["ReleaseProposalCard"]
Dash --> Playbook["PlaybookReviewQueue"]
@@ -151,7 +155,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── prompter/page.tsx (intake chat: single + MegaTask batch)
│ ├── a2a/page.tsx (A2A Live: switchboard/list + transcript + CEO reply/direct composer + New DM)
│ ├── settings/page.tsx + settings/ai-providers/page.tsx
── {agents,projects,products,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here)
│ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx
├── src/components/
│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity)
│ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table)
@@ -164,7 +169,8 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
│ ├── tasks/ + tasks/task-detail/ (task-table, create/edit-task-dialog, task-filters, acceptance-criteria-editor, dependency-selector; detail tabs: overview/plan/progress/commits/sessions/notes/dependencies/findings)
│ ├── settings/ (feature-flags-card, ai-routing-card, transcript-retention-card, self-hosted-section, x-credentials-card)
│ ├── conventions/conventions-tab.tsx (per-project architecture map + health)
│ ├── projects/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ ├── projects/projects-view.tsx, products/products-view.tsx (Workstation tab panes)
│ ├── projects/ products/ agents/ business/ auditor/ knowledge-base/ git/ journals/ work-sessions/ notifications/ rate-limit/ layout/
│ └── ui/ (Radix-based primitives: dialog, table, tabs, select, switch, required-notes-dialog, sonner toaster, markdown)
├── src/hooks/
│ ├── use-websocket.ts (shared useWebSocket<T>: auto-reconnect, heartbeat)
@@ -236,6 +242,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
- **`a2aApi.createConversation`/`sendCeoMessage` must pass `X-Agent-ID: "ceo"` explicitly** (via axios per-call `headers`) — the backend routes they hit resolve the caller's identity from that raw header rather than a DB lookup, so the client's *default* `CEO_AGENT_ID` (a UUID) would persist as `agent_a`/`from_agent` and break every downstream `"ceo"`-string check (reply-budget gate, reply-composer recipient exclusion, admin pairing). `client.ts`'s interceptor uses `has()`/`set()` (case-insensitive) specifically so this per-call override isn't clobbered — `AxiosHeaders` bracket access is case-sensitive and would have silently lost a lowercase key.
- **`A2ANewDmDialog`'s `AgentSelector` uses `excludeRoles`**, a new prop that drops roles from the roster before grouping (not just filters within a group) — used here to exclude the CEO itself plus every role without `read_a2a` on its manifest (auditor, pr_reviewer, prompter, secretary), since a DM to one of them would be a black hole no one ever reads.
- **Every URL param write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top** — `WorkSessionsView`'s search/status filters learned this the hard way (a per-keystroke `q=` param bounced the page) and moved to local `useState`; the Git page's own `?tab=` switch is fine since it's a deliberate, infrequent navigation, not per-keystroke. Don't route per-keystroke or high-frequency filter state through `router.replace`/`push` on this page.
- **Workstation's `ProjectsView` filters (q/cell/inactive) are local `useState`, not URL params** — deliberate: any URL write forks `ScrollRestoration`'s route key and force-scrolls `<main>` to top. Trade-off disclosed: a filtered Projects view is no longer a shareable/bookmarkable link (only `?tab=` rides the URL). `/products` and `/projects` are now redirect shims to `/workstation?tab=...`, so old bookmarks/links still resolve.
## Drift from CLAUDE.md
- CLAUDE.md says panel lives at `roboco/panel/` inside this repo — confirmed (no longer a separate `roboco-panel` project). No drift.
@@ -266,6 +273,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
> - `d83104e9` + `9a08cb3e` (2026-07-17, PR #546) — `ai-routing-card.tsx` confirm/toast copy now reads "Role/global routing now on … — per-agent pins kept" (was "All agents now on … Clears any overrides"), matching the backend fix that mode switches no longer wipe the whole `model_assignments` table — see `docs/map/support-services.md`.
> - **Wave 3** (2026-07-17, branch `feature/wave-3-a2a-ceo`, PR #547) — CEO New-DM composer: `a2a-new-dm-dialog.tsx` (opens a fresh CEO-owned 1:1, `AgentSelector`'s new `excludeRoles` prop) + `a2a-direct-composer.tsx` (posts in a CEO-owned thread, no task link needed) wired into `page.tsx`'s composer-selection branch (CEO-owned thread → direct composer; task-linked watched thread → reply composer; else read-only). `use-a2a-live.ts` adds `useCreateCeoConversation`/`useSendCeoMessage`; `lib/api/a2a.ts` adds `createConversation`/`sendCeoMessage` (both force `X-Agent-ID: "ceo"` per-call). `client.ts`'s header injection changed from an unconditional overwrite to a `has()`/`set()` default so a per-call override survives. Backend: `A2AService._maybe_wake_ceo_recipient` wakes an offline `read_a2a`-capable recipient of a CEO DM via the `a2a_request` dispatch path — see `docs/map/a2a-audit-journal-permissions.md`. Same branch also scrubbed "message the CEO" recipes from `docs/rag`/`agents/prompts` (agents are never taught to DM the CEO — reply-only).
> - (open PR #548, branch `feature/wave-2-hygiene-charts`, 2026-07-17) Wave 2 hygiene + charts: `/work-sessions` route now redirects to `/git?tab=sessions` (moved under Git as a "Work Sessions" tab, `git-page.tsx` gains a `Tabs`); `GitActionsPanel` gains a confirm-gated "Clean Up Stale Branches" button (`useCleanupBranches`, cursor-resumable); `WorkSessionsView`'s filters moved from URL params to local state (ScrollRestoration bounce fix); new `SessionTrendChart` / `CostTrendChart` / `SpendTrendChart`.
> - `dd4cb7f1` Wave 4: Workstation page (PR #549, 2026-07-17) — Products + Projects merged into one `/workstation?tab=` page (Products first); content extracted byte-faithfully into `components/products/products-view.tsx` + `components/projects/projects-view.tsx`; `products/page.tsx` + `projects/page.tsx` become redirect shims; the two sidebar entries collapse into one "Workstation" entry (`Briefcase` icon); `task-metadata.tsx`'s project-card link retargets to `/workstation?tab=projects`; `ProjectsView`'s q/cell/inactive filters move from URL params to local `useState` (scroll-bounce prevention, trades away shareable filtered-view links).
## Regression Risks
+4 -75
View File
@@ -1,78 +1,7 @@
"use client";
import { redirect } from "next/navigation";
import { Suspense, useEffect } from "react";
import { useProducts } from "@/hooks/use-products";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProductDialog, ProductTable } from "@/components/products";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageRefresh } from "@/hooks";
function ProductsPageContent() {
const { data: products, isLoading, error, refetch } = useProducts();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
Map each cell to the project it works on for a product
</p>
</div>
<div className="flex items-center gap-2">
<CreateProductDialog />
</div>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Products"
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
onRetry={() => void refresh()}
/>
) : (
<ProductTable products={products} isLoading={isLoading} />
)}
</div>
);
}
// Wrap in Suspense to match the dashboard page convention
// Products merged into the Workstation tab shell — see
// (dashboard)/workstation/page.tsx.
export default function ProductsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<Skeleton className="h-96 w-full" />
</div>
}
>
<ProductsPageContent />
</Suspense>
);
redirect("/workstation?tab=products");
}
+4 -177
View File
@@ -1,180 +1,7 @@
"use client";
import { redirect } from "next/navigation";
import { Suspense, useMemo, useCallback, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useProjects } from "@/hooks/use-projects";
import { Team } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import {
CreateProjectDialog,
ProjectFilters,
ProjectTable,
} from "@/components/projects";
import { Skeleton } from "@/components/ui/skeleton";
import { usePageRefresh } from "@/hooks";
function ProjectsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const searchQuery = searchParams.get("q") || "";
const cellFilterParam = searchParams.get("cell");
const cellFilter = useMemo(
() => (cellFilterParam?.split(",").filter(Boolean) as Team[]) || [],
[cellFilterParam],
);
const showInactive = searchParams.get("inactive") === "true";
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/projects?${query}` : "/projects");
},
[router, searchParams],
);
const handleSearchChange = useCallback(
(value: string) => {
updateParams({ q: value || null });
},
[updateParams],
);
const handleCellChange = useCallback(
(value: Team[]) => {
updateParams({ cell: value.length > 0 ? value.join(",") : null });
},
[updateParams],
);
const handleShowInactiveChange = useCallback(
(value: boolean) => {
updateParams({ inactive: value ? "true" : null });
},
[updateParams],
);
// Fetch projects
const {
data: projects,
isLoading,
error,
refetch,
} = useProjects({
active_only: !showInactive,
});
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter projects client-side for search and multi-select cell filter
const filteredProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => {
// Search filter
if (
searchQuery &&
!project.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Cell filter (if any selected, project must match one of them)
if (
cellFilter.length > 0 &&
!cellFilter.includes(project.assigned_cell)
) {
return false;
}
return true;
});
}, [projects, searchQuery, cellFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage git repositories and track development work
</p>
</div>
<div className="flex items-center gap-2">
<CreateProjectDialog />
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<ProjectFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
cellFilter={cellFilter}
onCellChange={handleCellChange}
showInactive={showInactive}
onShowInactiveChange={handleShowInactiveChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Projects"
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => void refresh()}
/>
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
// Projects merged into the Workstation tab shell — see
// (dashboard)/workstation/page.tsx.
export default function ProjectsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<ProjectsPageContent />
</Suspense>
);
redirect("/workstation?tab=projects");
}
@@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// The two tab panes have their own dedicated tests — stub them here so this
// page test only checks tab composition + the URL-driven default, mirroring
// social/__tests__/page.test.tsx.
vi.mock("@/components/products/products-view", () => ({
ProductsView: () => <div>ProductsViewStub</div>,
}));
vi.mock("@/components/projects/projects-view", () => ({
ProjectsView: () => <div>ProjectsViewStub</div>,
}));
const mockReplace = vi.fn();
let searchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => searchParams,
}));
import WorkstationPage from "../page";
describe("WorkstationPage", () => {
beforeEach(() => {
searchParams = new URLSearchParams();
mockReplace.mockClear();
});
it("defaults to the Products tab when the URL carries no ?tab", () => {
render(<WorkstationPage />);
expect(screen.getByRole("tab", { name: "Products" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "Projects" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("ProductsViewStub")).toBeInTheDocument();
});
it("activates the Projects tab from ?tab=projects", () => {
searchParams = new URLSearchParams("tab=projects");
render(<WorkstationPage />);
expect(screen.getByRole("tab", { name: "Projects" })).toHaveAttribute(
"data-state",
"active",
);
expect(screen.getByRole("tab", { name: "Products" })).toHaveAttribute(
"data-state",
"inactive",
);
expect(screen.getByText("ProjectsViewStub")).toBeInTheDocument();
});
});
@@ -0,0 +1,112 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { ProductsView } from "@/components/products/products-view";
import { ProjectsView } from "@/components/projects/projects-view";
// ---------------------------------------------------------------------------
// Valid tab values
// ---------------------------------------------------------------------------
interface TabDef {
value: "products" | "projects";
label: string;
hint: string;
}
const TAB_DEFS: TabDef[] = [
{
value: "products",
label: "Products",
hint: "Products the fleet ships against",
},
{
value: "projects",
label: "Projects",
hint: "Manage repos, git tokens, and per-project settings",
},
];
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
type TabValue = (typeof TAB_VALUES)[number];
function isValidTab(value: string | null): value is TabValue {
return TAB_VALUES.includes(value as TabValue);
}
// ---------------------------------------------------------------------------
// Inner component that reads URL params
// ---------------------------------------------------------------------------
function WorkstationPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const rawTab = searchParams.get("tab");
const activeTab: TabValue = isValidTab(rawTab) ? rawTab : "products";
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", value);
router.replace(`/workstation?${params.toString()}`);
};
return (
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{TAB_DEFS.map((tab) => (
<Tooltip key={tab.value}>
<TooltipTrigger asChild>
{/* TooltipTrigger's asChild Slot merge clobbers TabsTrigger's
own data-state; re-assert the real selection state
explicitly (see task-detail/task-tabs.tsx) so the
data-[state=active] styling still fires. */}
<TabsTrigger
value={tab.value}
data-state={tab.value === activeTab ? "active" : "inactive"}
>
{tab.label}
</TabsTrigger>
</TooltipTrigger>
<TooltipContent>{tab.hint}</TooltipContent>
</Tooltip>
))}
</TabsList>
<TabsContent value="products" className="mt-4">
<ProductsView />
</TabsContent>
<TabsContent value="projects" className="mt-4">
<ProjectsView />
</TabsContent>
</Tabs>
);
}
// ---------------------------------------------------------------------------
// Page export — wraps in Suspense for useSearchParams
// ---------------------------------------------------------------------------
export default function WorkstationPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-9 w-72" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<WorkstationPageContent />
</Suspense>
);
}
+5 -12
View File
@@ -14,8 +14,7 @@ import {
Bot,
Shield,
BookOpen,
Boxes,
FolderGit2,
Briefcase,
GitBranch,
Database,
Cpu,
@@ -70,16 +69,10 @@ export const navItems = [
tip: "Branches, commits, and diffs across every project workspace",
},
{
title: "Projects",
href: "/projects",
icon: FolderGit2,
tip: "Manage repos, git tokens, and per-project settings",
},
{
title: "Products",
href: "/products",
icon: Boxes,
tip: "Products the fleet ships against",
title: "Workstation",
href: "/workstation",
icon: Briefcase,
tip: "Products the fleet ships against, and the repos/projects behind them",
},
{
title: "Social",
+1
View File
@@ -1,3 +1,4 @@
export { ProductTable } from "./product-table";
export { CreateProductDialog } from "./create-product-dialog";
export { EditProductDialog } from "./edit-product-dialog";
export { ProductsView } from "./products-view";
@@ -0,0 +1,59 @@
"use client";
import { useEffect } from "react";
import { useProducts } from "@/hooks/use-products";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProductDialog } from "@/components/products/create-product-dialog";
import { ProductTable } from "@/components/products/product-table";
import { usePageRefresh } from "@/hooks";
/** Products tab content — extracted from the standalone /products page so it
* can live inside the Workstation tab shell (see workstation/page.tsx). */
export function ProductsView() {
const { data: products, isLoading, error, refetch } = useProducts();
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
<p className="text-muted-foreground">
Map each cell to the project it works on for a product
</p>
</div>
<div className="flex items-center gap-2">
<CreateProductDialog />
</div>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Products"
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
onRetry={() => void refresh()}
/>
) : (
<ProductTable products={products} isLoading={isLoading} />
)}
</div>
);
}
+1
View File
@@ -2,3 +2,4 @@ export { ProjectTable } from "./project-table";
export { ProjectFilters } from "./project-filters";
export { CreateProjectDialog } from "./create-project-dialog";
export { EditProjectDialog } from "./edit-project-dialog";
export { ProjectsView } from "./projects-view";
@@ -0,0 +1,116 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useProjects } from "@/hooks/use-projects";
import { Team } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProjectDialog } from "@/components/projects/create-project-dialog";
import { ProjectFilters } from "@/components/projects/project-filters";
import { ProjectTable } from "@/components/projects/project-table";
import { usePageRefresh } from "@/hooks";
/** Projects tab content — extracted from the standalone /projects page so it
* can live inside the Workstation tab shell (see workstation/page.tsx).
*
* Filter state is LOCAL, deliberately not URL params: every URL write forks
* ScrollRestoration's route key and force-scrolls <main> to top (the same
* bug class fixed in the work-sessions view — {scroll:false} does not
* prevent it). `tab` stays URL-owned by the workstation page shell; these
* filters don't ride the URL at all, so they reset on tab/page leave. */
export function ProjectsView() {
const [searchQuery, setSearchQuery] = useState("");
const [cellFilter, setCellFilter] = useState<Team[]>([]);
const [showInactive, setShowInactive] = useState(false);
// Fetch projects
const {
data: projects,
isLoading,
error,
refetch,
} = useProjects({
active_only: !showInactive,
});
const { register, unregister, refresh } = usePageRefresh();
useEffect(() => {
const cb = () => {
void refetch();
};
register(cb);
return () => unregister(cb);
}, [register, unregister, refetch]);
// Filter projects client-side for search and multi-select cell filter
const filteredProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => {
// Search filter
if (
searchQuery &&
!project.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Cell filter (if any selected, project must match one of them)
if (
cellFilter.length > 0 &&
!cellFilter.includes(project.assigned_cell)
) {
return false;
}
return true;
});
}, [projects, searchQuery, cellFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage git repositories and track development work
</p>
</div>
<div className="flex items-center gap-2">
<CreateProjectDialog />
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<ProjectFilters
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
cellFilter={cellFilter}
onCellChange={setCellFilter}
showInactive={showInactive}
onShowInactiveChange={setShowInactive}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Projects"
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => void refresh()}
/>
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
)}
</div>
);
}
@@ -560,7 +560,7 @@ export function TaskMetadata({ task }: TaskMetadataProps) {
<HelpTip label="Opens the Projects list (not a deep link to this specific project)">
<Link
prefetch={false}
href={`/projects`}
href={`/workstation?tab=projects`}
className="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{project.name}