diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index 9a637768..6669a08c 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -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(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=` + `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 `
` 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 `
` 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 diff --git a/docs/map/panel.md b/docs/map/panel.md index a485e7c4..4eedce03 100644 --- a/docs/map/panel.md +++ b/docs/map/panel.md @@ -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(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=` + `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 `
` 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 `
` 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 diff --git a/panel/src/components/products/__tests__/product-card-grid.test.tsx b/panel/src/components/products/__tests__/product-card-grid.test.tsx new file mode 100644 index 00000000..4cff40e7 --- /dev/null +++ b/panel/src/components/products/__tests__/product-card-grid.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.getByText("Unmapped")).toBeInTheDocument(); + }); + + it("renders loading skeletons and not the empty state while loading", () => { + render(); + expect(screen.queryByText("No products found")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/components/products/__tests__/products-view.test.tsx b/panel/src/components/products/__tests__/products-view.test.tsx new file mode 100644 index 00000000..14ae3a90 --- /dev/null +++ b/panel/src/components/products/__tests__/products-view.test.tsx @@ -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[] }) => ( +
+ {(products ?? []).map((p) => p.name).join(",")} +
+ ), +})); + +vi.mock("../product-table", () => ({ + ProductTable: ({ products }: { products?: ProductSummary[] }) => ( +
+ {(products ?? []).map((p) => p.name).join(",")} +
+ ), +})); + +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(); + 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(); + + 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(); + expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta"); + }); + + it("flips to descending when the direction toggle is clicked", async () => { + const user = userEvent.setup(); + render(); + 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"]); + }); +}); diff --git a/panel/src/components/products/index.ts b/panel/src/components/products/index.ts index 5a6fd629..fb79e76b 100644 --- a/panel/src/components/products/index.ts +++ b/panel/src/components/products/index.ts @@ -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"; diff --git a/panel/src/components/products/product-card-grid.tsx b/panel/src/components/products/product-card-grid.tsx new file mode 100644 index 00000000..37603ae7 --- /dev/null +++ b/panel/src/components/products/product-card-grid.tsx @@ -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(null); + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + ))} +
+ ); + } + + if (!products || products.length === 0) { + return ( +
+ +

No products found

+

Create a product to map cells to projects

+
+ ); + } + + return ( + <> +
+ {products.map((product) => ( + + +
+ + + + + + +
+ +

+ {product.slug} +

+
+
+ + + + +
+ ))} +
+ + {editingProductId && ( + { + if (!open) setEditingProductId(null); + }} + /> + )} + + ); +} diff --git a/panel/src/components/products/product-table.tsx b/panel/src/components/products/product-table.tsx index 67742fe0..d4566267 100644 --- a/panel/src/components/products/product-table.tsx +++ b/panel/src/components/products/product-table.tsx @@ -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 = { +export const TEAM_LABELS: Record = { 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 Unmapped; } @@ -62,7 +62,7 @@ function CellsList({ cells }: { cells: ProductSummary["cells"] }) { ); } -function ProgressCell({ +export function ProgressCell({ progress, }: { progress: ProductSummary["progress"]; diff --git a/panel/src/components/products/products-view.tsx b/panel/src/components/products/products-view.tsx index 902ca66c..9f778ebf 100644 --- a/panel/src/components/products/products-view.tsx +++ b/panel/src/components/products/products-view.tsx @@ -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("name"); + const [sortDirection, setSortDirection] = useState("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 (
{/* Header */} -
+

Products

@@ -40,6 +89,77 @@ export function ProductsView() {

+ {view === "cards" && ( + <> + + + + + + )} +
+ + + + + + +
@@ -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" ? ( + ) : ( )} diff --git a/panel/src/components/projects/__tests__/project-card-grid.test.tsx b/panel/src/components/projects/__tests__/project-card-grid.test.tsx new file mode 100644 index 00000000..8d3fb2b5 --- /dev/null +++ b/panel/src/components/projects/__tests__/project-card-grid.test.tsx @@ -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(); + 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(); + 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(); + expect(screen.getByText("bare-project")).toBeInTheDocument(); + expect(screen.queryByText("CI-Watch")).not.toBeInTheDocument(); + }); + + it("does not show the empty state while loading", () => { + render(); + expect(screen.queryByText("No projects found")).not.toBeInTheDocument(); + }); +}); diff --git a/panel/src/components/projects/__tests__/projects-view.test.tsx b/panel/src/components/projects/__tests__/projects-view.test.tsx new file mode 100644 index 00000000..8ae2547e --- /dev/null +++ b/panel/src/components/projects/__tests__/projects-view.test.tsx @@ -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[] }) => ( +
+ {(projects ?? []).map((p) => p.name).join(",")} +
+ ), +})); + +vi.mock("../project-table", async () => { + const actual = await vi.importActual( + "../project-table", + ); + return { + teamLabels: actual.teamLabels, + ProjectTable: ({ projects }: { projects?: ProjectSummary[] }) => ( +
+ {(projects ?? []).map((p) => p.name).join(",")} +
+ ), + }; +}); + +import { ProjectsView, sortProjects } from "../projects-view"; +import { useUIStore } from "@/store/ui-store"; + +function makeProject(overrides: Partial): 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(); + 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(); + + 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(); + expect(screen.getByTestId("card-grid")).toHaveTextContent("Alpha,Zeta"); + }); + + it("flips to descending when the direction toggle is clicked", async () => { + const user = userEvent.setup(); + render(); + 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"]); + }); +}); diff --git a/panel/src/components/projects/index.ts b/panel/src/components/projects/index.ts index ec50ffec..bd95f99d 100644 --- a/panel/src/components/projects/index.ts +++ b/panel/src/components/projects/index.ts @@ -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"; diff --git a/panel/src/components/projects/project-card-grid.tsx b/panel/src/components/projects/project-card-grid.tsx new file mode 100644 index 00000000..b8b2a00b --- /dev/null +++ b/panel/src/components/projects/project-card-grid.tsx @@ -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(null); + + if (isLoading) { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + ))} +
+ ); + } + + if (!projects || projects.length === 0) { + return ( +
+ +

No projects found

+

+ Create a project to get started with git integration +

+
+ ); + } + + return ( + <> +
+ {projects.map((project) => ( + + +
+ + + +
+ + + + + + +
+
+ +

+ {project.slug} +

+
+
+ +
+ + + {teamLabels[project.assigned_cell]} + + + {getStatusBadge(project.is_active)} + {getTokenBadge(project.has_git_token)} + {project.ci_watch_enabled && } +
+ +
+
+ ))} +
+ + {editingProjectId && ( + { + if (!open) setEditingProjectId(null); + }} + /> + )} + + ); +} diff --git a/panel/src/components/projects/project-table.tsx b/panel/src/components/projects/project-table.tsx index a1e7256d..3f7dd5c0 100644 --- a/panel/src/components/projects/project-table.tsx +++ b/panel/src/components/projects/project-table.tsx @@ -28,7 +28,7 @@ interface ProjectTableProps { isLoading: boolean; } -const teamLabels: Record = { +export const teamLabels: Record = { board: "Board", main_pm: "Main PM", backend: "Backend", @@ -37,7 +37,7 @@ const teamLabels: Record = { marketing: "Marketing", }; -const teamColors: Record = { +export const teamColors: Record = { 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 = { 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 ? ( @@ -65,7 +65,7 @@ function getTokenBadge(hasGitToken: boolean) { ); } -function getStatusBadge(isActive: boolean) { +export function getStatusBadge(isActive: boolean) { const badge = isActive ? ( Active ) : ( @@ -79,7 +79,7 @@ function getStatusBadge(isActive: boolean) { return {badge}; } -function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) { +export function TasksCell({ counts }: { counts: ProjectTaskCounts | null }) { if (!counts) { return ; } @@ -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 ( @@ -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) { + 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(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 ( <> + 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([]); const [showInactive, setShowInactive] = useState(false); + const view = useUIStore((s) => s.projectsView); + const setView = useUIStore((s) => s.setProjectsView); + const [sortKey, setSortKey] = useState("name"); + const [sortDirection, setSortDirection] = useState("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 (
{/* Header */} -
+

Projects

@@ -85,6 +139,77 @@ export function ProjectsView() {

+ {view === "cards" && ( + <> + + + + + + )} +
+ + + + + + +
@@ -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" ? ( + ) : ( )} diff --git a/panel/src/store/ui-store.ts b/panel/src/store/ui-store.ts index 8b8283cb..a445a7ca 100644 --- a/panel/src/store/ui-store.ts +++ b/panel/src/store/ui-store.ts @@ -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()( 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()( 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()( theme: state.theme, currentTeam: state.currentTeam, a2aContextOpen: state.a2aContextOpen, + productsView: state.productsView, + projectsView: state.projectsView, notificationsEnabled: state.notificationsEnabled, soundEnabled: state.soundEnabled, autoRefresh: state.autoRefresh,