mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: Workstation card grids — view toggle, sorting, agent-card visual language (#556)
* feat(panel): Workstation card grids with persisted view toggle and sorting * fix(panel): stable multiplier sort with cell-label fallback after adversarial review * docs(map): panel entries for this wave --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -8660,7 +8660,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/`, `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/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`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers); `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 |
|
||||
@@ -8688,7 +8688,7 @@ 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 |
|
||||
| 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) |
|
||||
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; 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` |
|
||||
@@ -8735,6 +8735,10 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `WorkSessionsView` | comp | `components/work-sessions/work-sessions-view.tsx` | Git page's "Work Sessions" tab body; search/status filters are LOCAL `useState`, not URL params |
|
||||
| `SessionTrendChart` | comp | `components/work-sessions/session-trend-chart.tsx` | Active-session start-time histogram (hourly/daily bucketing); honestly labeled active-only, no history beyond `GET /work-sessions` |
|
||||
| `CostTrendChart` / `SpendTrendChart` | comp | `components/dashboard/cost-trend-chart.tsx` / `components/business/spend-trend-chart.tsx` | Daily-spend area charts off `GET /usage/time-series`; 7d on Overview (`CommandCenter`), 30d on the Business scorecard (`CompanyScorecardCard`) |
|
||||
| `ProductCardGrid` | comp | `components/products/product-card-grid.tsx` | Workstation Products card-grid view; reuses `CellsList`/`ProgressCell` (exported from `product-table.tsx`) |
|
||||
| `ProjectCardGrid` | comp | `components/projects/project-card-grid.tsx` | Workstation Projects card-grid view; reuses `getExternalUrl`/badge renderers (exported from `project-table.tsx`) |
|
||||
| `sortProducts` | fn | `components/products/products-view.tsx` | Pure client-side sort (name/cell count) for the Products card grid; direction rides a comparator multiplier, not sort-then-reverse, so ties keep their relative order |
|
||||
| `sortProjects` | fn | `components/projects/projects-view.tsx` | Pure client-side sort (name/cell) for the Projects card grid; same multiplier pattern, plus a `teamLabels[cell] ?? String(cell)` fallback so a backend `Team` value outside the panel's map never crashes the sort |
|
||||
|
||||
## Data Flow
|
||||
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus` → `websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
|
||||
@@ -8799,7 +8803,7 @@ 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/projects-view.tsx, products/products-view.tsx (Workstation tab panes)
|
||||
│ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers)
|
||||
│ ├── 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/
|
||||
@@ -8875,6 +8879,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
- **`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.
|
||||
- **Card-grid sort direction uses a comparator multiplier, not sort-then-reverse** (`sortProducts`/`sortProjects` in `products-view.tsx`/`projects-view.tsx`, mirrors `task-table.tsx`'s pattern) — reversing an already-sorted array also flips the relative order of ties; a first cut of the Workstation card sort got this wrong and was fixed post-adversarial-review (9a33efba, PR #556). `sortProjects`' cell-key comparator also falls back to the raw `assigned_cell` string when it's outside the panel's `teamLabels` map (the backend `Team` enum is a superset — e.g. `fullstack`/`system`) instead of throwing on an unmapped key.
|
||||
|
||||
## 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.
|
||||
@@ -8906,6 +8911,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
> - **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).
|
||||
> - `49a87c2b`+`9a33efba` (2026-07-18, PR #556, Workstation card grids) — new `product-card-grid.tsx`/`project-card-grid.tsx` (same intrinsic `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]` sizing as `agent-grid.tsx`), reusing `CellsList`/`ProgressCell` (newly exported from `product-table.tsx`) and `getExternalUrl`/badge renderers (newly exported from `project-table.tsx`); each Workstation surface gains a Cards\|Table toggle (default Cards, persisted per-surface via `ui-store`'s new `productsView`/`projectsView`) plus a name/cell(-count) sort control that only applies in card view — the table keeps its own unsorted render. `sortProducts`/`sortProjects` (both exported for direct unit tests) use a direction multiplier rather than sort-then-reverse, since reversing also flips the relative order of ties, and `sortProjects`' cell-key comparator falls back to the raw `assigned_cell` value for a backend `Team` outside the panel's `teamLabels` map instead of crashing; the fix commit tightened both to this shape after adversarial review caught the initial sort-then-reverse cut.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
+9
-3
@@ -32,7 +32,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `panel/src/lib/telegram/webapp.ts` | Thin typed wrapper over the global `window.Telegram.WebApp` (`ready`/`expand`/`initData`); `waitForTelegramWebApp` polls (100ms, 1.5s timeout) for the CDN script since it loads `afterInteractive` |
|
||||
| `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/`, `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/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`, plus a `*-card-grid.tsx` reusing the sibling table's exported badge renderers); `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 |
|
||||
@@ -60,7 +60,7 @@ 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 |
|
||||
| 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) |
|
||||
| Workstation | `app/(dashboard)/workstation/page.tsx` + `components/{products,projects}/*-view.tsx` | Products + Projects as one sidebar entry, tab-switched via `?tab=`; each surface has a Cards\|Table view toggle (default Cards, persisted per-surface via `ui-store`'s `productsView`/`projectsView`) with client-side name/cell(-count) sorting in card view; 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` |
|
||||
@@ -108,6 +108,10 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s
|
||||
| `WorkSessionsView` | comp | `components/work-sessions/work-sessions-view.tsx` | Git page's "Work Sessions" tab body; search/status filters are LOCAL `useState`, not URL params |
|
||||
| `SessionTrendChart` | comp | `components/work-sessions/session-trend-chart.tsx` | Active-session start-time histogram (hourly/daily bucketing); honestly labeled active-only, no history beyond `GET /work-sessions` |
|
||||
| `CostTrendChart` / `SpendTrendChart` | comp | `components/dashboard/cost-trend-chart.tsx` / `components/business/spend-trend-chart.tsx` | Daily-spend area charts off `GET /usage/time-series`; 7d on Overview (`CommandCenter`), 30d on the Business scorecard (`CompanyScorecardCard`) |
|
||||
| `ProductCardGrid` | comp | `components/products/product-card-grid.tsx` | Workstation Products card-grid view; reuses `CellsList`/`ProgressCell` (exported from `product-table.tsx`) |
|
||||
| `ProjectCardGrid` | comp | `components/projects/project-card-grid.tsx` | Workstation Projects card-grid view; reuses `getExternalUrl`/badge renderers (exported from `project-table.tsx`) |
|
||||
| `sortProducts` | fn | `components/products/products-view.tsx` | Pure client-side sort (name/cell count) for the Products card grid; direction rides a comparator multiplier, not sort-then-reverse, so ties keep their relative order |
|
||||
| `sortProjects` | fn | `components/projects/projects-view.tsx` | Pure client-side sort (name/cell) for the Projects card grid; same multiplier pattern, plus a `teamLabels[cell] ?? String(cell)` fallback so a backend `Team` value outside the panel's map never crashes the sort |
|
||||
|
||||
## Data Flow
|
||||
Browser → nginx :3000 → (panel Next.js server for pages; `/api/*` and `/ws/*` proxied to `orchestrator:8000`). All client calls use relative URLs: `API_URL="/api"` (axios `baseURL`) and `WS_URL="/ws"` (`getWebSocketUrl`) — no CORS because the browser sees one origin. When cloud auth is armed (`ROBOCO_CLOUD_AUTH_ENABLED`), every navigation to a `(dashboard)` route first runs `proxy.ts` (Next 16's rename of `middleware.ts`), which probes `/auth/status` directly against the docker-internal orchestrator URL (not through nginx) and redirects to `/login` when no `roboco_session` cookie is present; a probe failure/timeout fails OPEN to "cloud auth off" so a slow/unreachable backend never blocks navigation. The login page (`(auth)/login/page.tsx`) posts credentials via `authApi.login` (OAuth2 form body, FastAPI Users' cookie route) and the session cookie rides back on the response. The shared axios client DEFAULTS `X-Agent-ID=<CEO_AGENT_ID>` + `X-Agent-Role=CEO_ROLE` headers for API authorization — `has()`/`set()`, not a flat overwrite, so a call that already set its own headers (the CEO-DM composer's `X-Agent-ID: "ceo"`, needed literally by its route) keeps them. Live events flow: orchestrator `StreamEventBus` → `websocket_bridge` → per-resource `/ws/{agents,notifications,system}` sockets → panel `useWebSocket` hooks → zustand stores / TanStack Query cache. Usage snapshots (`USAGE_SNAPSHOT`) and rate-limit lifecycle (`RATE_LIMIT_HIT/LIFTED`) arrive on the single shared `/ws/system` stream mounted in providers; on any non-`connected` state the usage store clears its snapshot so the panel falls back to HTTP-polling summary until a fresh frame lands. The A2A page's `useA2ALiveStream` is a second, independent consumer of that same shared `/ws/system` connection (not a new socket): every persisted A2A message publishes an `a2a.message` frame, which the page uses purely to invalidate-on-frame (REST via `a2aApi` stays the source of truth for full message bodies, since the frame's excerpt is capped) and to drive the switchboard's 45s pulse fade on the matching pair card.
|
||||
@@ -176,7 +180,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0)
|
||||
│ ├── tg/ (tg-tab-bar, tg-approvals-tab, tg-inbox-tab, tg-board-tab, tg-chat-tab — the /tg cockpit's own tabs)
|
||||
│ ├── 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/projects-view.tsx, products/products-view.tsx (Workstation tab panes)
|
||||
│ ├── projects/projects-view.tsx + project-card-grid.tsx, products/products-view.tsx + product-card-grid.tsx (Workstation tab panes; card grids reuse the sibling table's exported badge renderers)
|
||||
│ ├── 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/
|
||||
@@ -255,6 +259,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
- **`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.
|
||||
- **Card-grid sort direction uses a comparator multiplier, not sort-then-reverse** (`sortProducts`/`sortProjects` in `products-view.tsx`/`projects-view.tsx`, mirrors `task-table.tsx`'s pattern) — reversing an already-sorted array also flips the relative order of ties; a first cut of the Workstation card sort got this wrong and was fixed post-adversarial-review (9a33efba, PR #556). `sortProjects`' cell-key comparator also falls back to the raw `assigned_cell` string when it's outside the panel's `teamLabels` map (the backend `Team` enum is a superset — e.g. `fullstack`/`system`) instead of throwing on an unmapped key.
|
||||
|
||||
## 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.
|
||||
@@ -287,6 +292,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog
|
||||
> - (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).
|
||||
> - `e16fb634`+`8d727785` (2026-07-18, PR #554, Telegram V3 Mini App) — new `(tg)` route group (`layout.tsx` + `tg/page.tsx`) and `components/tg/{tg-tab-bar,tg-approvals-tab,tg-inbox-tab,tg-board-tab,tg-chat-tab}.tsx`; new `components/tasks/mobile-task-board.tsx` (read-only, grouped-by-status) and `lib/telegram/webapp.ts` (WebApp bridge + `waitForTelegramWebApp` poll); `a2a-new-dm-dialog.tsx` exports `EXCLUDE_NON_DM_ROLES` for `tg-chat-tab.tsx` to reuse; `use-a2a-live.ts`'s `useA2AMessages` gains an optional `refetchInterval` (the cockpit polls instead of using WS); `proxy.ts` matcher excludes `tg(?:/|$)` (anchored in the fix commit — see Gotchas). Backend companion: `POST /api/telegram/webapp-auth` — see `docs/map/api-routes-schemas.md`.
|
||||
> - `49a87c2b`+`9a33efba` (2026-07-18, PR #556, Workstation card grids) — new `product-card-grid.tsx`/`project-card-grid.tsx` (same intrinsic `grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]` sizing as `agent-grid.tsx`), reusing `CellsList`/`ProgressCell` (newly exported from `product-table.tsx`) and `getExternalUrl`/badge renderers (newly exported from `project-table.tsx`); each Workstation surface gains a Cards\|Table toggle (default Cards, persisted per-surface via `ui-store`'s new `productsView`/`projectsView`) plus a name/cell(-count) sort control that only applies in card view — the table keeps its own unsorted render. `sortProducts`/`sortProjects` (both exported for direct unit tests) use a direction multiplier rather than sort-then-reverse, since reversing also flips the relative order of ties, and `sortProjects`' cell-key comparator falls back to the raw `assigned_cell` value for a backend `Team` outside the panel's `teamLabels` map instead of crashing; the fix commit tightened both to this shape after adversarial review caught the initial sort-then-reverse cut.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { ProductCardGrid } from "../product-card-grid";
|
||||
import { Team } from "@/types";
|
||||
import type { ProductSummary } from "@/types";
|
||||
|
||||
const product: ProductSummary = {
|
||||
id: "p1",
|
||||
name: "RoboCo Platform",
|
||||
slug: "roboco-platform",
|
||||
cell_count: 3,
|
||||
cells: [
|
||||
{ team: Team.BACKEND, project_id: "proj-1", project_name: "roboco" },
|
||||
{ team: Team.FRONTEND, project_id: "proj-1", project_name: "roboco" },
|
||||
],
|
||||
progress: { done: 42, active: 5, blocked: 1 },
|
||||
};
|
||||
|
||||
describe("ProductCardGrid", () => {
|
||||
it("renders one card carrying the product's name, cells, and progress", () => {
|
||||
render(<ProductCardGrid products={[product]} isLoading={false} />);
|
||||
expect(screen.getByText("RoboCo Platform")).toBeInTheDocument();
|
||||
expect(screen.getByText("roboco-platform")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("roboco").length).toBe(2);
|
||||
expect(screen.getByText("42 done")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 blocked")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no products", () => {
|
||||
render(<ProductCardGrid products={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No products found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the unmapped label for a product with no cells", () => {
|
||||
const bare: ProductSummary = {
|
||||
id: "p2",
|
||||
name: "Bare Product",
|
||||
slug: "bare",
|
||||
cell_count: 0,
|
||||
cells: [],
|
||||
progress: { done: 0, active: 0, blocked: 0 },
|
||||
};
|
||||
render(<ProductCardGrid products={[bare]} isLoading={false} />);
|
||||
expect(screen.getByText("Unmapped")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders loading skeletons and not the empty state while loading", () => {
|
||||
render(<ProductCardGrid products={undefined} isLoading={true} />);
|
||||
expect(screen.queryByText("No products found")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ProductSummary } from "@/types";
|
||||
|
||||
vi.mock("@/hooks/use-page-refresh", () => ({
|
||||
usePageRefresh: () => ({
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useProducts } = vi.hoisted(() => ({ useProducts: vi.fn() }));
|
||||
vi.mock("@/hooks/use-products", () => ({ useProducts }));
|
||||
|
||||
vi.mock("../create-product-dialog", () => ({
|
||||
CreateProductDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../product-card-grid", () => ({
|
||||
ProductCardGrid: ({ products }: { products?: ProductSummary[] }) => (
|
||||
<div data-testid="card-grid">
|
||||
{(products ?? []).map((p) => p.name).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../product-table", () => ({
|
||||
ProductTable: ({ products }: { products?: ProductSummary[] }) => (
|
||||
<div data-testid="table">
|
||||
{(products ?? []).map((p) => p.name).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { ProductsView, sortProducts } from "../products-view";
|
||||
import { useUIStore } from "@/store/ui-store";
|
||||
|
||||
const PRODUCTS: ProductSummary[] = [
|
||||
{
|
||||
id: "p-zeta",
|
||||
name: "Zeta",
|
||||
slug: "zeta",
|
||||
cell_count: 1,
|
||||
cells: [],
|
||||
progress: { done: 0, active: 0, blocked: 0 },
|
||||
},
|
||||
{
|
||||
id: "p-alpha",
|
||||
name: "Alpha",
|
||||
slug: "alpha",
|
||||
cell_count: 3,
|
||||
cells: [],
|
||||
progress: { done: 0, active: 0, blocked: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProductsView", () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ productsView: "cards" });
|
||||
useProducts.mockReturnValue({
|
||||
data: PRODUCTS,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to the card grid view", () => {
|
||||
render(<ProductsView />);
|
||||
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the table view and back via the toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProductsView />);
|
||||
|
||||
await user.click(screen.getByLabelText("Table view"));
|
||||
expect(screen.getByTestId("table")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-grid")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Card view"));
|
||||
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts cards by name ascending by default", () => {
|
||||
render(<ProductsView />);
|
||||
expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta");
|
||||
});
|
||||
|
||||
it("flips to descending when the direction toggle is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProductsView />);
|
||||
await user.click(screen.getByLabelText("Toggle sort direction"));
|
||||
expect(screen.getByTestId("card-grid")).toHaveTextContent("Zeta,Alpha");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortProducts (pure)", () => {
|
||||
const prod = (name: string, cell_count: number) =>
|
||||
({ name, cell_count }) as unknown as ProductSummary;
|
||||
|
||||
it("desc preserves the relative order of ties", () => {
|
||||
const rows = [prod("A", 2), prod("B", 2), prod("C", 2)];
|
||||
const out = sortProducts(rows, "cells", "desc");
|
||||
expect(out.map((r) => r.name)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ProductTable } from "./product-table";
|
||||
export { ProductCardGrid } from "./product-card-grid";
|
||||
export { CreateProductDialog } from "./create-product-dialog";
|
||||
export { EditProductDialog } from "./edit-product-dialog";
|
||||
export { ProductsView } from "./products-view";
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Boxes, Pencil } from "lucide-react";
|
||||
import type { ProductSummary } from "@/types";
|
||||
import { EditProductDialog } from "./edit-product-dialog";
|
||||
import { CellsList, ProgressCell } from "./product-table";
|
||||
|
||||
interface ProductCardGridProps {
|
||||
products: ProductSummary[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Same intrinsic-sizing grid as the Agents page card grid (agent-grid.tsx):
|
||||
// one column on a phone, as many as fit at 17rem+ on a wide monitor.
|
||||
const GRID_COLS = "grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]";
|
||||
|
||||
export function ProductCardGrid({ products, isLoading }: ProductCardGridProps) {
|
||||
const [editingProductId, setEditingProductId] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"grid gap-3 " + GRID_COLS}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="gap-2.5 py-4">
|
||||
<CardHeader className="gap-1 px-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Boxes className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">No products found</p>
|
||||
<p className="text-sm">Create a product to map cells to projects</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"grid gap-3 " + GRID_COLS}>
|
||||
{products.map((product) => (
|
||||
<Card key={product.id} className="gap-2.5 py-4">
|
||||
<CardHeader className="gap-1 px-4">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
<Button
|
||||
onClick={() => setEditingProductId(product.id)}
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-semibold text-base text-foreground"
|
||||
>
|
||||
{product.name}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<HelpTip label="Edit product name, description, and cell-project mapping">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => setEditingProductId(product.id)}
|
||||
aria-label="Edit product"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<HelpTip label="Identifier for this cell-to-project grouping, used to reference it across the panel and API.">
|
||||
<p className="w-fit truncate text-xs text-muted-foreground font-mono">
|
||||
{product.slug}
|
||||
</p>
|
||||
</HelpTip>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-2.5">
|
||||
<CellsList cells={product.cells} />
|
||||
<ProgressCell progress={product.progress} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editingProductId && (
|
||||
<EditProductDialog
|
||||
productId={editingProductId}
|
||||
open={!!editingProductId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingProductId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import type { ProductSummary, Team } from "@/types";
|
||||
import { EditProductDialog } from "./edit-product-dialog";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
|
||||
const TEAM_LABELS: Record<Team, string> = {
|
||||
export const TEAM_LABELS: Record<Team, string> = {
|
||||
board: "Board",
|
||||
main_pm: "Main PM",
|
||||
backend: "Backend",
|
||||
@@ -37,7 +37,7 @@ interface ProductTableProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
|
||||
export function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
|
||||
if (cells.length === 0) {
|
||||
return <span className="text-muted-foreground text-sm">Unmapped</span>;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressCell({
|
||||
export function ProgressCell({
|
||||
progress,
|
||||
}: {
|
||||
progress: ProductSummary["progress"];
|
||||
|
||||
@@ -1,16 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useProducts } from "@/hooks/use-products";
|
||||
import { useUIStore } from "@/store/ui-store";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CreateProductDialog } from "@/components/products/create-product-dialog";
|
||||
import { ProductCardGrid } from "@/components/products/product-card-grid";
|
||||
import { ProductTable } from "@/components/products/product-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ArrowDown, ArrowUp, LayoutGrid, Table2 } from "lucide-react";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
import type { ProductSummary } from "@/types";
|
||||
|
||||
type ProductSortKey = "name" | "cells";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const SORT_OPTIONS: { value: ProductSortKey; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "cells", label: "Cell count" },
|
||||
];
|
||||
|
||||
// Exported for direct unit tests. Multiplier, not sort-then-reverse — see
|
||||
// sortProjects in projects-view.tsx.
|
||||
export function sortProducts(
|
||||
products: ProductSummary[],
|
||||
key: ProductSortKey,
|
||||
direction: SortDirection,
|
||||
): ProductSummary[] {
|
||||
const dir = direction === "asc" ? 1 : -1;
|
||||
return [...products].sort(
|
||||
(a, b) =>
|
||||
dir *
|
||||
(key === "name"
|
||||
? a.name.localeCompare(b.name)
|
||||
: a.cell_count - b.cell_count),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 view = useUIStore((s) => s.productsView);
|
||||
const setView = useUIStore((s) => s.setProductsView);
|
||||
const [sortKey, setSortKey] = useState<ProductSortKey>("name");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
|
||||
const { register, unregister, refresh } = usePageRefresh();
|
||||
|
||||
@@ -22,6 +64,13 @@ export function ProductsView() {
|
||||
return () => unregister(cb);
|
||||
}, [register, unregister, refetch]);
|
||||
|
||||
// Sorting is client-side over the already-loaded list — only relevant to
|
||||
// the card view; the table keeps its own (unsorted, creation-order) render.
|
||||
const sortedProducts = useMemo(
|
||||
() => (products ? sortProducts(products, sortKey, sortDirection) : products),
|
||||
[products, sortKey, sortDirection],
|
||||
);
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
@@ -32,7 +81,7 @@ export function ProductsView() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -40,6 +89,77 @@ export function ProductsView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{view === "cards" && (
|
||||
<>
|
||||
<Select
|
||||
value={sortKey}
|
||||
onValueChange={(v) => setSortKey(v as ProductSortKey)}
|
||||
>
|
||||
<HelpTip label="Sort the product cards by this field">
|
||||
<SelectTrigger size="sm" className="w-auto min-w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</HelpTip>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpTip
|
||||
label={
|
||||
sortDirection === "asc"
|
||||
? "Ascending — click for descending"
|
||||
: "Descending — click for ascending"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"))
|
||||
}
|
||||
aria-label="Toggle sort direction"
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1 rounded-md border p-0.5">
|
||||
<HelpTip label="Card view — boxes with badges, one per product">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "cards" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
aria-pressed={view === "cards"}
|
||||
aria-label="Card view"
|
||||
onClick={() => setView("cards")}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Table view">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "table" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
aria-pressed={view === "table"}
|
||||
aria-label="Table view"
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<CreateProductDialog />
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +171,8 @@ export function ProductsView() {
|
||||
description="Start the RoboCo orchestrator to manage products. Products map cells to the projects they work on."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : view === "cards" ? (
|
||||
<ProductCardGrid products={sortedProducts} isLoading={isLoading} />
|
||||
) : (
|
||||
<ProductTable products={products} isLoading={isLoading} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { ProjectCardGrid } from "../project-card-grid";
|
||||
import { Team } from "@/types";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
|
||||
const project: ProjectSummary = {
|
||||
id: "p1",
|
||||
name: "RoboCo Core",
|
||||
slug: "roboco",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
assigned_cell: Team.BACKEND,
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
ci_watch_enabled: true,
|
||||
task_counts: { done: 42, active: 5, blocked: 1 },
|
||||
};
|
||||
|
||||
describe("ProjectCardGrid", () => {
|
||||
it("renders one card carrying the project's name, cell, tasks, token, status, and CI-watch badge", () => {
|
||||
render(<ProjectCardGrid projects={[project]} isLoading={false} />);
|
||||
expect(screen.getByText("RoboCo Core")).toBeInTheDocument();
|
||||
expect(screen.getByText("roboco")).toBeInTheDocument();
|
||||
expect(screen.getByText("Backend")).toBeInTheDocument();
|
||||
expect(screen.getByText("42 done")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 blocked")).toBeInTheDocument();
|
||||
expect(screen.getByText("Token Set")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("CI-Watch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no projects", () => {
|
||||
render(<ProjectCardGrid projects={[]} isLoading={false} />);
|
||||
expect(screen.getByText("No projects found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an em-dash placeholder when task_counts is null and omits the CI-Watch badge", () => {
|
||||
const bare: ProjectSummary = {
|
||||
...project,
|
||||
id: "p2",
|
||||
name: "bare-project",
|
||||
ci_watch_enabled: false,
|
||||
task_counts: null,
|
||||
};
|
||||
render(<ProjectCardGrid projects={[bare]} isLoading={false} />);
|
||||
expect(screen.getByText("bare-project")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the empty state while loading", () => {
|
||||
render(<ProjectCardGrid projects={undefined} isLoading={true} />);
|
||||
expect(screen.queryByText("No projects found")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Team } from "@/types";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
|
||||
vi.mock("@/hooks/use-page-refresh", () => ({
|
||||
usePageRefresh: () => ({
|
||||
register: vi.fn(),
|
||||
unregister: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { useProjects } = vi.hoisted(() => ({ useProjects: vi.fn() }));
|
||||
vi.mock("@/hooks/use-projects", () => ({ useProjects }));
|
||||
|
||||
vi.mock("../create-project-dialog", () => ({
|
||||
CreateProjectDialog: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../project-card-grid", () => ({
|
||||
ProjectCardGrid: ({ projects }: { projects?: ProjectSummary[] }) => (
|
||||
<div data-testid="card-grid">
|
||||
{(projects ?? []).map((p) => p.name).join(",")}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../project-table", async () => {
|
||||
const actual = await vi.importActual<typeof import("../project-table")>(
|
||||
"../project-table",
|
||||
);
|
||||
return {
|
||||
teamLabels: actual.teamLabels,
|
||||
ProjectTable: ({ projects }: { projects?: ProjectSummary[] }) => (
|
||||
<div data-testid="table">
|
||||
{(projects ?? []).map((p) => p.name).join(",")}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { ProjectsView, sortProjects } from "../projects-view";
|
||||
import { useUIStore } from "@/store/ui-store";
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
return {
|
||||
id: overrides.id ?? "p1",
|
||||
name: overrides.name ?? "Project",
|
||||
slug: overrides.slug ?? "project",
|
||||
git_url: "https://github.com/rennf93/roboco.git",
|
||||
assigned_cell: Team.BACKEND,
|
||||
is_active: true,
|
||||
has_workspace: true,
|
||||
has_git_token: true,
|
||||
video_engine_enabled: false,
|
||||
ci_watch_enabled: false,
|
||||
task_counts: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTS: ProjectSummary[] = [
|
||||
makeProject({ id: "p-zeta", name: "Zeta", assigned_cell: Team.FRONTEND }),
|
||||
makeProject({ id: "p-alpha", name: "Alpha", assigned_cell: Team.BACKEND }),
|
||||
];
|
||||
|
||||
describe("ProjectsView", () => {
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ projectsView: "cards" });
|
||||
useProjects.mockReturnValue({
|
||||
data: PROJECTS,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to the card grid view", () => {
|
||||
render(<ProjectsView />);
|
||||
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to the table view and back via the toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectsView />);
|
||||
|
||||
await user.click(screen.getByLabelText("Table view"));
|
||||
expect(screen.getByTestId("table")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-grid")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByLabelText("Card view"));
|
||||
expect(screen.getByTestId("card-grid")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sorts cards by name ascending by default", () => {
|
||||
render(<ProjectsView />);
|
||||
expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta");
|
||||
});
|
||||
|
||||
it("flips to descending when the direction toggle is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectsView />);
|
||||
await user.click(screen.getByLabelText("Toggle sort direction"));
|
||||
expect(screen.getByTestId("card-grid")).toHaveTextContent("Zeta,Alpha");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortProjects (pure)", () => {
|
||||
const proj = (name: string, cell: string) =>
|
||||
({ name, assigned_cell: cell }) as unknown as ProjectSummary;
|
||||
|
||||
it("does not crash on a backend-only cell value and sorts it by raw value", () => {
|
||||
const rows = [proj("a", "system"), proj("b", "backend")];
|
||||
const out = sortProjects(rows, "cell", "asc");
|
||||
// "Backend" < "system" (localeCompare, case-aware) — the point is no throw.
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("desc preserves the relative order of ties (stable, not reversed)", () => {
|
||||
const rows = [proj("A", "backend"), proj("B", "backend"), proj("C", "backend")];
|
||||
const out = sortProjects(rows, "cell", "desc");
|
||||
expect(out.map((r) => r.name)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ProjectTable } from "./project-table";
|
||||
export { ProjectCardGrid } from "./project-card-grid";
|
||||
export { ProjectFilters } from "./project-filters";
|
||||
export { CreateProjectDialog } from "./create-project-dialog";
|
||||
export { EditProjectDialog } from "./edit-project-dialog";
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ExternalLink, GitBranch, Pencil } from "lucide-react";
|
||||
import type { ProjectSummary } from "@/types";
|
||||
import { EditProjectDialog } from "./edit-project-dialog";
|
||||
import {
|
||||
CiWatchBadge,
|
||||
TasksCell,
|
||||
getExternalUrl,
|
||||
getStatusBadge,
|
||||
getTokenBadge,
|
||||
teamColors,
|
||||
teamLabels,
|
||||
} from "./project-table";
|
||||
|
||||
interface ProjectCardGridProps {
|
||||
projects: ProjectSummary[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Same intrinsic-sizing grid as the Agents page card grid (agent-grid.tsx):
|
||||
// one column on a phone, as many as fit at 17rem+ on a wide monitor.
|
||||
const GRID_COLS = "grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]";
|
||||
|
||||
export function ProjectCardGrid({ projects, isLoading }: ProjectCardGridProps) {
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"grid gap-3 " + GRID_COLS}>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="gap-2.5 py-4">
|
||||
<CardHeader className="gap-1 px-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projects || projects.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<GitBranch className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">No projects found</p>
|
||||
<p className="text-sm">
|
||||
Create a project to get started with git integration
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"grid gap-3 " + GRID_COLS}>
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="gap-2.5 py-4">
|
||||
<CardHeader className="gap-1 px-4">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<CardTitle className="min-w-0 truncate text-base">
|
||||
<Button
|
||||
onClick={() => setEditingProjectId(project.id)}
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-semibold text-base text-foreground"
|
||||
>
|
||||
{project.name}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<HelpTip label="Edit project settings and CI/CD commands">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setEditingProjectId(project.id)}
|
||||
aria-label="Edit project"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Open the git repository in a new tab">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" asChild>
|
||||
<a
|
||||
href={getExternalUrl(project)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View repository"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
</div>
|
||||
<HelpTip label="Composes each agent's workspace clone path and every branch name for this project.">
|
||||
<p className="w-fit truncate text-xs text-muted-foreground font-mono">
|
||||
{project.slug}
|
||||
</p>
|
||||
</HelpTip>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 space-y-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<HelpTip label="Only this cell's agents can claim tasks on this project.">
|
||||
<Badge className={teamColors[project.assigned_cell]}>
|
||||
{teamLabels[project.assigned_cell]}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
{getStatusBadge(project.is_active)}
|
||||
{getTokenBadge(project.has_git_token)}
|
||||
{project.ci_watch_enabled && <CiWatchBadge enabled />}
|
||||
</div>
|
||||
<TasksCell counts={project.task_counts} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editingProjectId && (
|
||||
<EditProjectDialog
|
||||
projectId={editingProjectId}
|
||||
open={!!editingProjectId}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingProjectId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ interface ProjectTableProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const teamLabels: Record<Team, string> = {
|
||||
export const teamLabels: Record<Team, string> = {
|
||||
board: "Board",
|
||||
main_pm: "Main PM",
|
||||
backend: "Backend",
|
||||
@@ -37,7 +37,7 @@ const teamLabels: Record<Team, string> = {
|
||||
marketing: "Marketing",
|
||||
};
|
||||
|
||||
const teamColors: Record<Team, string> = {
|
||||
export const teamColors: Record<Team, string> = {
|
||||
board: "bg-purple-500/10 text-purple-500 hover:bg-purple-500/20",
|
||||
main_pm: "bg-blue-500/10 text-blue-500 hover:bg-blue-500/20",
|
||||
backend: "bg-green-500/10 text-green-500 hover:bg-green-500/20",
|
||||
@@ -46,7 +46,7 @@ const teamColors: Record<Team, string> = {
|
||||
marketing: "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20",
|
||||
};
|
||||
|
||||
function getTokenBadge(hasGitToken: boolean) {
|
||||
export function getTokenBadge(hasGitToken: boolean) {
|
||||
const badge = hasGitToken ? (
|
||||
<Badge className="bg-green-500/10 text-green-500">
|
||||
<Key className="h-3 w-3 mr-1" />
|
||||
@@ -65,7 +65,7 @@ function getTokenBadge(hasGitToken: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusBadge(isActive: boolean) {
|
||||
export function getStatusBadge(isActive: boolean) {
|
||||
const badge = isActive ? (
|
||||
<Badge className="bg-green-500/10 text-green-500">Active</Badge>
|
||||
) : (
|
||||
@@ -79,7 +79,7 @@ function getStatusBadge(isActive: boolean) {
|
||||
return <HelpTip label={hint}>{badge}</HelpTip>;
|
||||
}
|
||||
|
||||
function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
|
||||
export function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
|
||||
if (!counts) {
|
||||
return <span className="text-muted-foreground text-xs">—</span>;
|
||||
}
|
||||
@@ -118,7 +118,7 @@ function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CiWatchBadge({ enabled }: { enabled: boolean }) {
|
||||
export function CiWatchBadge({ enabled }: { enabled: boolean }) {
|
||||
if (!enabled) return null;
|
||||
return (
|
||||
<HelpTip label="Opens a fix task automatically when this project's CI goes red on its default branch.">
|
||||
@@ -133,6 +133,20 @@ function CiWatchBadge({ enabled }: { enabled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Converts a git URL to a browsable HTTPS URL (strip .git suffix, handle SSH
|
||||
// format). Module-level (not component-local) so the card grid view can
|
||||
// reuse it verbatim for the same "View repository" action.
|
||||
export function getExternalUrl(project: Pick<ProjectSummary, "git_url">) {
|
||||
let url = project.git_url;
|
||||
if (url.endsWith(".git")) {
|
||||
url = url.slice(0, -4);
|
||||
}
|
||||
if (url.startsWith("git@")) {
|
||||
url = url.replace("git@", "https://").replace(":", "/");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
||||
|
||||
@@ -158,20 +172,6 @@ export function ProjectTable({ projects, isLoading }: ProjectTableProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Convert git URL to browsable URL (strip .git suffix, handle SSH format)
|
||||
const getExternalUrl = (project: ProjectSummary) => {
|
||||
let url = project.git_url;
|
||||
// Remove .git suffix
|
||||
if (url.endsWith(".git")) {
|
||||
url = url.slice(0, -4);
|
||||
}
|
||||
// Convert SSH format (git@github.com:org/repo) to HTTPS
|
||||
if (url.startsWith("git@")) {
|
||||
url = url.replace("git@", "https://").replace(":", "/");
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveTable
|
||||
|
||||
@@ -2,13 +2,56 @@
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useUIStore } from "@/store/ui-store";
|
||||
import { Team } from "@/types";
|
||||
import type { ProjectSummary } 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 { ProjectCardGrid } from "@/components/projects/project-card-grid";
|
||||
import { ProjectTable, teamLabels } from "@/components/projects/project-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ArrowDown, ArrowUp, LayoutGrid, Table2 } from "lucide-react";
|
||||
import { usePageRefresh } from "@/hooks";
|
||||
|
||||
type ProjectSortKey = "name" | "cell";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const SORT_OPTIONS: { value: ProjectSortKey; label: string }[] = [
|
||||
{ value: "name", label: "Name" },
|
||||
{ value: "cell", label: "Cell" },
|
||||
];
|
||||
|
||||
// Exported for direct unit tests. Direction rides a comparator multiplier
|
||||
// (task-table.tsx's pattern), NOT sort-then-reverse — reversing also flips
|
||||
// the relative order of ties. The label fallback matters: the backend Team
|
||||
// enum is a superset of the panel's (fullstack/system), and an out-of-map
|
||||
// cell must sort by its raw value, never crash the view.
|
||||
export function sortProjects(
|
||||
projects: ProjectSummary[],
|
||||
key: ProjectSortKey,
|
||||
direction: SortDirection,
|
||||
): ProjectSummary[] {
|
||||
const dir = direction === "asc" ? 1 : -1;
|
||||
const cellLabel = (cell: ProjectSummary["assigned_cell"]) =>
|
||||
teamLabels[cell] ?? String(cell);
|
||||
return [...projects].sort(
|
||||
(a, b) =>
|
||||
dir *
|
||||
(key === "name"
|
||||
? a.name.localeCompare(b.name)
|
||||
: cellLabel(a.assigned_cell).localeCompare(cellLabel(b.assigned_cell))),
|
||||
);
|
||||
}
|
||||
|
||||
/** Projects tab content — extracted from the standalone /projects page so it
|
||||
* can live inside the Workstation tab shell (see workstation/page.tsx).
|
||||
*
|
||||
@@ -21,6 +64,10 @@ export function ProjectsView() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [cellFilter, setCellFilter] = useState<Team[]>([]);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const view = useUIStore((s) => s.projectsView);
|
||||
const setView = useUIStore((s) => s.setProjectsView);
|
||||
const [sortKey, setSortKey] = useState<ProjectSortKey>("name");
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
||||
|
||||
// Fetch projects
|
||||
const {
|
||||
@@ -67,6 +114,13 @@ export function ProjectsView() {
|
||||
});
|
||||
}, [projects, searchQuery, cellFilter]);
|
||||
|
||||
// Sorting is client-side over the filtered list — only relevant to the
|
||||
// card view; the table keeps its own (unsorted, filter-order) render.
|
||||
const sortedProjects = useMemo(
|
||||
() => sortProjects(filteredProjects, sortKey, sortDirection),
|
||||
[filteredProjects, sortKey, sortDirection],
|
||||
);
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline =
|
||||
error &&
|
||||
@@ -77,7 +131,7 @@ export function ProjectsView() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -85,6 +139,77 @@ export function ProjectsView() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{view === "cards" && (
|
||||
<>
|
||||
<Select
|
||||
value={sortKey}
|
||||
onValueChange={(v) => setSortKey(v as ProjectSortKey)}
|
||||
>
|
||||
<HelpTip label="Sort the project cards by this field">
|
||||
<SelectTrigger size="sm" className="w-auto min-w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</HelpTip>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpTip
|
||||
label={
|
||||
sortDirection === "asc"
|
||||
? "Ascending — click for descending"
|
||||
: "Descending — click for ascending"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSortDirection((d) => (d === "asc" ? "desc" : "asc"))
|
||||
}
|
||||
aria-label="Toggle sort direction"
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1 rounded-md border p-0.5">
|
||||
<HelpTip label="Card view — boxes with badges, one per project">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "cards" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
aria-pressed={view === "cards"}
|
||||
aria-label="Card view"
|
||||
onClick={() => setView("cards")}
|
||||
>
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Table view">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "table" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
aria-pressed={view === "table"}
|
||||
aria-label="Table view"
|
||||
onClick={() => setView("table")}
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<CreateProjectDialog />
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,6 +233,8 @@ export function ProjectsView() {
|
||||
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
|
||||
onRetry={() => void refresh()}
|
||||
/>
|
||||
) : view === "cards" ? (
|
||||
<ProjectCardGrid projects={sortedProjects} isLoading={isLoading} />
|
||||
) : (
|
||||
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,11 @@ interface UIState {
|
||||
// design doc §1) — same persisted-preference idiom as sidebar/theme.
|
||||
a2aContextOpen: boolean;
|
||||
|
||||
// Workstation cards/table toggle, persisted per-surface so Products and
|
||||
// Projects remember their own choice independently. Cards is the default.
|
||||
productsView: "cards" | "table";
|
||||
projectsView: "cards" | "table";
|
||||
|
||||
// Client-only Settings-page prefs (never sent to the backend — the
|
||||
// server's settings allowlist is transcript_retention_days + feature
|
||||
// flags only). Same persisted-preference idiom as sidebar/theme.
|
||||
@@ -31,6 +36,8 @@ interface UIState {
|
||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
||||
setCurrentTeam: (team: Team | null) => void;
|
||||
toggleA2AContext: () => void;
|
||||
setProductsView: (view: "cards" | "table") => void;
|
||||
setProjectsView: (view: "cards" | "table") => void;
|
||||
setNotificationsEnabled: (enabled: boolean) => void;
|
||||
setSoundEnabled: (enabled: boolean) => void;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
@@ -45,6 +52,8 @@ export const useUIStore = create<UIState>()(
|
||||
theme: "system",
|
||||
currentTeam: null,
|
||||
a2aContextOpen: true,
|
||||
productsView: "cards",
|
||||
projectsView: "cards",
|
||||
notificationsEnabled: true,
|
||||
soundEnabled: true,
|
||||
autoRefresh: false, // default-off: never start a background poller unasked
|
||||
@@ -57,6 +66,8 @@ export const useUIStore = create<UIState>()(
|
||||
setCurrentTeam: (team) => set({ currentTeam: team }),
|
||||
toggleA2AContext: () =>
|
||||
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
|
||||
setProductsView: (view) => set({ productsView: view }),
|
||||
setProjectsView: (view) => set({ projectsView: view }),
|
||||
setNotificationsEnabled: (enabled) =>
|
||||
set({ notificationsEnabled: enabled }),
|
||||
setSoundEnabled: (enabled) => set({ soundEnabled: enabled }),
|
||||
@@ -71,6 +82,8 @@ export const useUIStore = create<UIState>()(
|
||||
theme: state.theme,
|
||||
currentTeam: state.currentTeam,
|
||||
a2aContextOpen: state.a2aContextOpen,
|
||||
productsView: state.productsView,
|
||||
projectsView: state.projectsView,
|
||||
notificationsEnabled: state.notificationsEnabled,
|
||||
soundEnabled: state.soundEnabled,
|
||||
autoRefresh: state.autoRefresh,
|
||||
|
||||
Reference in New Issue
Block a user