diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index 6669a08c..270b9a2d 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -8652,7 +8652,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s | `panel/src/app/(dashboard)/{agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx` | Per-domain pages | | `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin` → `/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group | | `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present | -| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` | +| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` | | `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table | | `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban | | `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card | @@ -8729,6 +8729,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s | `XCredentialsCard` | comp | `components/settings/x-credentials-card.tsx` | Write-only entry of the 4 OAuth 1.0a secrets; set-all-4 or clear-all-4 | | `RequiredNotesDialog` | comp | `components/ui/required-notes-dialog.tsx` | Reusable notes-gated confirm; submit disabled on empty/whitespace | | `CommandCenter` | comp | `components/dashboard/command-center.tsx` | Overview page body; composes all dashboard cards | +| `QUICK_ACTIONS_REGISTRY` | const | `components/dashboard/quick-actions-registry.ts` | 23-action catalog (id/label/icon/href/tip) derived from the sidebar route surface + tab-parameterized deep links; `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy `QuickActionsBar` destination; `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids | +| `QuickActionsCard` / `QuickActionsCustomizeDialog` | comp | `components/dashboard/quick-actions-card.tsx` | Chosen-order icon+label grid off `useUIStore.quickActionIds` (empty-state message when none selected); pencil-icon dialog toggles/reorders via checkbox + up/down arrows and resets to defaults, persisted through `setQuickActionIds`/`resetQuickActionIds` | | `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels | | `GitActionsPanel` | comp | `components/git/git-actions-panel.tsx` | Commit/push/PR/rebase actions + destructive-confirm "Clean Up Stale Branches" (`AlertDialog`) | | `useCleanupBranches` / `handleCleanupBranches` | hook | `hooks/use-git.ts` / `hooks/use-git-browser.ts` | Mutation over `POST /git/branches/cleanup`; the browser hook tracks a per-project cursor ref so a repeat click resumes a truncated sweep instead of re-scanning the first window | @@ -8792,7 +8794,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0) │ ├── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here) │ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx ├── src/components/ -│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity) +│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity) │ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table) │ ├── kanban/ │ │ ├── core/ (kanban-board/column/card + bypass-preconditions) @@ -8912,6 +8914,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). > - `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. +> - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`. ## Regression Risks diff --git a/docs/map/panel.md b/docs/map/panel.md index 4eedce03..902d2aaf 100644 --- a/docs/map/panel.md +++ b/docs/map/panel.md @@ -22,7 +22,7 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s | `panel/src/app/(auth)/login/page.tsx` | Cloud-auth login form (email/password → `useLogin` → `/auth/login`); only reachable/relevant once `proxy.ts` starts gating the `(dashboard)` group | | `panel/src/app/(tg)/layout.tsx` + `(tg)/tg/page.tsx` | Telegram Mini App cockpit at `/tg`: slim shell (no sidebar/header, `next/script` loads the Telegram WebApp bridge `afterInteractive`) + bootstrap page that resolves `window.Telegram.WebApp`, POSTs its `initData` to `/telegram/webapp-auth` unconditionally, then renders the tabbed cockpit (or an "Open from Telegram" / error state) | | `panel/src/proxy.ts` | Next 16's rename of `middleware.ts`: probes `/auth/status` (docker-internal orchestrator URL, fails open to "off" on any error/timeout) and redirects to `/login` when cloud auth is on and no session cookie is present; matcher excludes `tg(?:/|$)` — the Mini App authenticates via Telegram `initData`, not the password cookie, so it must never be redirected to `/login` | -| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` | +| `panel/src/components/dashboard/` | Overview cards: command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity, `x-post-queue.tsx`, `roadmap-review-queue.tsx` | | `panel/src/components/metrics/` | delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table | | `panel/src/components/kanban/{core,shared,views}/` | core: kanban-board/column/card + bypass-preconditions; views: dev/qa/pm/pr-review kanban | | `panel/src/components/prompter/` | intake-form, chat-messages, chat-composer, draft-proposal-card, batch-review-card, success-card, board-review-sent-card | @@ -102,6 +102,8 @@ The Next.js 16 control panel (`panel/`, package `roboco-panel` v0.14.0) is the s | `XCredentialsCard` | comp | `components/settings/x-credentials-card.tsx` | Write-only entry of the 4 OAuth 1.0a secrets; set-all-4 or clear-all-4 | | `RequiredNotesDialog` | comp | `components/ui/required-notes-dialog.tsx` | Reusable notes-gated confirm; submit disabled on empty/whitespace | | `CommandCenter` | comp | `components/dashboard/command-center.tsx` | Overview page body; composes all dashboard cards | +| `QUICK_ACTIONS_REGISTRY` | const | `components/dashboard/quick-actions-registry.ts` | 23-action catalog (id/label/icon/href/tip) derived from the sidebar route surface + tab-parameterized deep links; `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy `QuickActionsBar` destination; `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids | +| `QuickActionsCard` / `QuickActionsCustomizeDialog` | comp | `components/dashboard/quick-actions-card.tsx` | Chosen-order icon+label grid off `useUIStore.quickActionIds` (empty-state message when none selected); pencil-icon dialog toggles/reorders via checkbox + up/down arrows and resets to defaults, persisted through `setQuickActionIds`/`resetQuickActionIds` | | `DeliveryTabContent` | comp | `components/metrics/delivery-tab.tsx` | Cycle-time/bottleneck/rework/scorecard panels | | `GitActionsPanel` | comp | `components/git/git-actions-panel.tsx` | Commit/push/PR/rebase actions + destructive-confirm "Clean Up Stale Branches" (`AlertDialog`) | | `useCleanupBranches` / `handleCleanupBranches` | hook | `hooks/use-git.ts` / `hooks/use-git-browser.ts` | Mutation over `POST /git/branches/cleanup`; the browser hook tracks a per-project cursor ref so a repeat click resumes a truncated sweep instead of re-scanning the first window | @@ -168,7 +170,7 @@ panel/ (Next.js 16, package roboco-panel v0.14.0) │ ├── workstation/page.tsx (Products + Projects tabs; projects/, products/ page.tsx now redirect here) │ └── {agents,business,journals,git,knowledge-base,auditor,work-sessions,notifications}/page.tsx ├── src/components/ -│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions, recent-activity) +│ ├── dashboard/ (command-center, key-metrics, release-proposal, playbook-review-queue, ceo-approval-queue, pr-review-queue, x-post-queue, roadmap-review-queue, usage-overview, team-health, active-blockers, auditor-alerts, strategy-signals, quick-actions-card, quick-actions-registry, recent-activity) │ ├── metrics/ (delivery-tab, usage-time-series-chart, agent/team-usage-chart, model-usage-donut, sessions-table) │ ├── kanban/ │ │ ├── core/ (kanban-board/column/card + bypass-preconditions) @@ -293,6 +295,7 @@ Deliberately **not** on this card (compose/env-coupled, unsafe for a runtime tog > - `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. +> - `97306fe7`+`151d6e0f` (2026-07-18, PR #557, customizable Quick Actions) — the hardcoded `QuickActionsBar` (deleted) is replaced by `QuickActionsCard` (`components/dashboard/quick-actions-card.tsx`), backed by a new 23-action `QUICK_ACTIONS_REGISTRY` (`components/dashboard/quick-actions-registry.ts`, id/label/icon/href/tip per entry, tab-parameterized deep links e.g. `/git?tab=sessions`, `/metrics?tab=delivery`); `DEFAULT_QUICK_ACTION_IDS` absorbs every legacy bar destination (secretary/journals/auditor) so the swap drops nothing from a fresh install's default view. Selection persists as `quickActionIds` on `useUIStore` (new key in `partialize`, contract-tested); `resolveQuickActions`/`isKnownQuickActionId` drop stale persisted ids instead of crashing. A pencil-icon `QuickActionsCustomizeDialog` (inline in `quick-actions-card.tsx`) lets the CEO show/hide + reorder (up/down arrows) + reset to defaults; the card shows an empty-state message when every action is hidden. `command-center.tsx` and its test swap `QuickActionsBar`/`QuickActionsBarStub` references for `QuickActionsCard`/`QuickActionsCardStub`. ## Regression Risks diff --git a/panel/src/components/dashboard/__tests__/command-center.test.tsx b/panel/src/components/dashboard/__tests__/command-center.test.tsx index ca09b5c1..d03cfa9b 100644 --- a/panel/src/components/dashboard/__tests__/command-center.test.tsx +++ b/panel/src/components/dashboard/__tests__/command-center.test.tsx @@ -55,8 +55,8 @@ vi.mock("../active-blockers-panel", () => ({ vi.mock("../recent-activity-feed", () => ({ RecentActivityFeed: () =>
RecentActivityFeedStub
, })); -vi.mock("../quick-actions-bar", () => ({ - QuickActionsBar: () =>
QuickActionsBarStub
, +vi.mock("../quick-actions-card", () => ({ + QuickActionsCard: () =>
QuickActionsCardStub
, })); vi.mock("../ceo-approval-queue", () => ({ CeoApprovalQueue: () =>
CeoApprovalQueueStub
, @@ -111,7 +111,7 @@ describe("CommandCenter", () => { it("renders every section", () => { render(); for (const stub of [ - "QuickActionsBarStub", + "QuickActionsCardStub", "KeyMetricsPanelStub", "AuditorAlertsPanelStub", "UsageOverviewPanelStub", @@ -135,7 +135,7 @@ describe("CommandCenter", () => { it("orders sections top to bottom: quick actions/key cards, team health, then the rest", () => { render(); const order = [ - "QuickActionsBarStub", + "QuickActionsCardStub", "KeyMetricsPanelStub", "TeamHealthCardsStub", "CeoApprovalQueueStub", diff --git a/panel/src/components/dashboard/__tests__/quick-actions-card.test.tsx b/panel/src/components/dashboard/__tests__/quick-actions-card.test.tsx new file mode 100644 index 00000000..5693a91f --- /dev/null +++ b/panel/src/components/dashboard/__tests__/quick-actions-card.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useUIStore } from "@/store"; +import { DEFAULT_QUICK_ACTION_IDS } from "../quick-actions-registry"; +import { QuickActionsCard } from "../quick-actions-card"; + +function resetStore(quickActionIds: string[] = DEFAULT_QUICK_ACTION_IDS) { + useUIStore.setState({ quickActionIds }); +} + +describe("QuickActionsCard", () => { + beforeEach(() => { + resetStore(); + }); + + it("renders the default action set as links to their real routes on a fresh store", () => { + render(); + + expect(screen.getByRole("link", { name: /New Task/i })).toHaveAttribute( + "href", + "/prompter", + ); + expect(screen.getByRole("link", { name: /^Tasks$/i })).toHaveAttribute( + "href", + "/tasks", + ); + expect(screen.getByRole("link", { name: /Settings/i })).toHaveAttribute( + "href", + "/settings", + ); + }); + + it("drops a stale id (removed from the registry) without crashing", () => { + resetStore(["tasks", "this-action-no-longer-exists", "settings"]); + + render(); + + expect(screen.getByRole("link", { name: /^Tasks$/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /Settings/i })).toBeInTheDocument(); + expect(screen.getAllByRole("link")).toHaveLength(2); + }); + + it("renders nothing but the customize button when the stored list is entirely stale", () => { + resetStore(["ghost-one", "ghost-two"]); + + render(); + + expect(screen.queryAllByRole("link")).toHaveLength(0); + expect( + screen.getByRole("button", { name: "Customize Quick Actions" }), + ).toBeInTheDocument(); + }); + + describe("customize dialog", () => { + async function openDialog(user: ReturnType) { + render(); + await user.click( + screen.getByRole("button", { name: "Customize Quick Actions" }), + ); + return screen.getByRole("dialog"); + } + + it("unchecking an action removes it from the card and persists to the store", async () => { + const user = userEvent.setup(); + const dialog = await openDialog(user); + + await user.click( + within(dialog).getByRole("checkbox", { name: "Show Tasks" }), + ); + + expect(useUIStore.getState().quickActionIds).not.toContain("tasks"); + }); + + it("checking an action not in the default set adds it", async () => { + const user = userEvent.setup(); + const dialog = await openDialog(user); + + // Social isn't in DEFAULT_QUICK_ACTION_IDS (auditor now is — the + // legacy bar's actions all ride the defaults). + await user.click( + within(dialog).getByRole("checkbox", { name: "Show Social" }), + ); + + expect(useUIStore.getState().quickActionIds).toContain("social"); + }); + + it("reorders with the move-later arrow", async () => { + const user = userEvent.setup(); + const dialog = await openDialog(user); + + const before = useUIStore.getState().quickActionIds; + expect(before[0]).toBe("new-task"); + + await user.click( + within(dialog).getByRole("button", { name: "Move New Task later" }), + ); + + const after = useUIStore.getState().quickActionIds; + expect(after[1]).toBe("new-task"); + expect(after[0]).toBe(before[1]); + }); + + it("the move-earlier arrow is disabled for the first row", async () => { + const user = userEvent.setup(); + const dialog = await openDialog(user); + + expect( + within(dialog).getByRole("button", { name: "Move New Task earlier" }), + ).toBeDisabled(); + }); + + it("resets to defaults", async () => { + const user = userEvent.setup(); + const dialog = await openDialog(user); + + await user.click( + within(dialog).getByRole("checkbox", { name: "Show Tasks" }), + ); + expect(useUIStore.getState().quickActionIds).not.toEqual( + DEFAULT_QUICK_ACTION_IDS, + ); + + await user.click( + within(dialog).getByRole("button", { name: "Reset to defaults" }), + ); + + expect(useUIStore.getState().quickActionIds).toEqual( + DEFAULT_QUICK_ACTION_IDS, + ); + }); + + it("drops a stale id from the checklist instead of rendering it", async () => { + const user = userEvent.setup(); + resetStore(["tasks", "ghost-stale"]); + render(); + await user.click( + screen.getByRole("button", { name: "Customize Quick Actions" }), + ); + const dialog = screen.getByRole("dialog"); + + expect(within(dialog).queryByText("ghost-stale")).not.toBeInTheDocument(); + expect( + within(dialog).getByRole("checkbox", { name: "Show Tasks" }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/panel/src/components/dashboard/__tests__/quick-actions-registry.test.ts b/panel/src/components/dashboard/__tests__/quick-actions-registry.test.ts new file mode 100644 index 00000000..b8eb8d4a --- /dev/null +++ b/panel/src/components/dashboard/__tests__/quick-actions-registry.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { + QUICK_ACTIONS_REGISTRY, + DEFAULT_QUICK_ACTION_IDS, + resolveQuickActions, + isKnownQuickActionId, +} from "../quick-actions-registry"; + +describe("quick-actions-registry", () => { + it("has unique ids", () => { + const ids = QUICK_ACTIONS_REGISTRY.map((a) => a.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every action has a non-empty, absolute href", () => { + for (const action of QUICK_ACTIONS_REGISTRY) { + expect(action.href).toBeTruthy(); + expect(action.href.startsWith("/")).toBe(true); + } + }); + + it("every action has a non-empty label, tip, and icon", () => { + for (const action of QUICK_ACTIONS_REGISTRY) { + expect(action.label).toBeTruthy(); + expect(action.tip).toBeTruthy(); + expect(action.icon).toBeTruthy(); + } + }); + + it("default ids all resolve to real registry entries", () => { + for (const id of DEFAULT_QUICK_ACTION_IDS) { + expect(isKnownQuickActionId(id)).toBe(true); + } + }); + + it("resolveQuickActions preserves order and drops unknown ids", () => { + const resolved = resolveQuickActions([ + "settings", + "does-not-exist", + "tasks", + ]); + expect(resolved.map((a) => a.id)).toEqual(["settings", "tasks"]); + }); + + it("resolveQuickActions returns an empty list for an all-stale input", () => { + expect(resolveQuickActions(["ghost-1", "ghost-2"])).toEqual([]); + }); +}); diff --git a/panel/src/components/dashboard/command-center.tsx b/panel/src/components/dashboard/command-center.tsx index a44ec93d..7f92438d 100644 --- a/panel/src/components/dashboard/command-center.tsx +++ b/panel/src/components/dashboard/command-center.tsx @@ -14,7 +14,7 @@ import { KeyMetricsPanel } from "./key-metrics-panel"; import { AuditorAlertsPanel } from "./auditor-alerts-panel"; import { ActiveBlockersPanel } from "./active-blockers-panel"; import { RecentActivityFeed } from "./recent-activity-feed"; -import { QuickActionsBar } from "./quick-actions-bar"; +import { QuickActionsCard } from "./quick-actions-card"; import { CeoApprovalQueue } from "./ceo-approval-queue"; import { PrReviewQueue } from "./pr-review-queue"; import { ReleaseProposalCard } from "./release-proposal-card"; @@ -154,7 +154,7 @@ export function CommandCenter() { Quick Actions - +
diff --git a/panel/src/components/dashboard/index.ts b/panel/src/components/dashboard/index.ts index 8467d065..81eed1f5 100644 --- a/panel/src/components/dashboard/index.ts +++ b/panel/src/components/dashboard/index.ts @@ -7,7 +7,7 @@ export { AuditorAlertsPanel } from "./auditor-alerts-panel"; export { ActiveBlockersPanel } from "./active-blockers-panel"; export { RecentActivityFeed } from "./recent-activity-feed"; export { ActivityItem } from "./activity-item"; -export { QuickActionsBar } from "./quick-actions-bar"; +export { QuickActionsCard } from "./quick-actions-card"; export { HealthIndicator } from "./health-indicator"; export { CeoApprovalQueue } from "./ceo-approval-queue"; export { ReleaseProposalCard } from "./release-proposal-card"; diff --git a/panel/src/components/dashboard/quick-actions-bar.tsx b/panel/src/components/dashboard/quick-actions-bar.tsx deleted file mode 100644 index 64957769..00000000 --- a/panel/src/components/dashboard/quick-actions-bar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { CreateTaskDialog } from "@/components/tasks/create-task-dialog"; -import { HelpTip } from "@/components/ui/help-tip"; -import { Users, BookOpen, Shield, Sparkles, Bot } from "lucide-react"; -import Link from "next/link"; - -export function QuickActionsBar() { - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -} diff --git a/panel/src/components/dashboard/quick-actions-card.tsx b/panel/src/components/dashboard/quick-actions-card.tsx new file mode 100644 index 00000000..f6c66df4 --- /dev/null +++ b/panel/src/components/dashboard/quick-actions-card.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { HelpTip } from "@/components/ui/help-tip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { ArrowDown, ArrowUp, Pencil, RotateCcw } from "lucide-react"; +import { useUIStore } from "@/store"; +import { + QUICK_ACTIONS_REGISTRY, + resolveQuickActions, +} from "./quick-actions-registry"; + +// Per-user honesty: the panel is a single-operator surface today. This +// customization is persisted per-browser via the existing localStorage-backed +// useUIStore — different browsers/profiles keep their own chosen set, which +// IS the "different people need different quick access buttons" mechanism +// for now. No server-side per-user storage is built. + +/** + * Compact icon+label grid of the CEO's chosen quick actions, in their chosen + * order — replaces the old hardcoded QuickActionsBar. A gear/pencil affordance + * opens the customize dialog (below) to pick which actions show and reorder + * them. + */ +export function QuickActionsCard() { + const quickActionIds = useUIStore((s) => s.quickActionIds); + const actions = resolveQuickActions(quickActionIds); + + return ( +
+ {actions.length === 0 && ( +

+ No quick actions selected — use the pencil to add some. +

+ )} +
+ {actions.map((action) => ( + + + + + + ))} +
+ +
+ ); +} + +const CUSTOMIZE_LABEL = "Customize Quick Actions"; + +function QuickActionsCustomizeDialog() { + const [open, setOpen] = useState(false); + const quickActionIds = useUIStore((s) => s.quickActionIds); + const setQuickActionIds = useUIStore((s) => s.setQuickActionIds); + const resetQuickActionIds = useUIStore((s) => s.resetQuickActionIds); + + // Stale ids (an action removed from the registry since it was picked) are + // dropped here too — the dialog only ever shows/reorders real actions. + const enabledIds = resolveQuickActions(quickActionIds).map((a) => a.id); + const enabledSet = new Set(enabledIds); + // Enabled actions first (in the user's chosen order), then everything else + // available to add, in registry order. + const orderedIds = [ + ...enabledIds, + ...QUICK_ACTIONS_REGISTRY.filter((a) => !enabledSet.has(a.id)).map( + (a) => a.id, + ), + ]; + + const toggle = (id: string) => { + if (enabledSet.has(id)) { + setQuickActionIds(enabledIds.filter((x) => x !== id)); + } else { + setQuickActionIds([...enabledIds, id]); + } + }; + + const move = (id: string, direction: -1 | 1) => { + const index = enabledIds.indexOf(id); + if (index === -1) return; + const target = index + direction; + if (target < 0 || target >= enabledIds.length) return; + const next = [...enabledIds]; + [next[index], next[target]] = [next[target], next[index]]; + setQuickActionIds(next); + }; + + return ( + + + + + + + + + Customize Quick Actions + + Pick which shortcuts show on the Overview dashboard and reorder + them with the arrows. Saved to this browser only. + + + +
+ {orderedIds.map((id) => { + const action = QUICK_ACTIONS_REGISTRY.find((a) => a.id === id); + if (!action) return null; + const isEnabled = enabledSet.has(id); + const index = enabledIds.indexOf(id); + const isFirst = index === 0; + const isLast = index === enabledIds.length - 1; + return ( +
+ toggle(id)} + aria-label={`Show ${action.label}`} + /> + + + + {action.label} + + + {isEnabled && ( +
+ + + + + + + Move earlier + + + + + + + + Move later + +
+ )} +
+ ); + })} +
+ + +
+
+ ); +} diff --git a/panel/src/components/dashboard/quick-actions-registry.ts b/panel/src/components/dashboard/quick-actions-registry.ts new file mode 100644 index 00000000..60d4121e --- /dev/null +++ b/panel/src/components/dashboard/quick-actions-registry.ts @@ -0,0 +1,241 @@ +import { + Sparkles, + ListTodo, + Kanban, + GitBranch, + Briefcase, + Share2, + Database, + Radio, + Bot, + BookOpen, + Shield, + Activity, + Building2, + Cpu, + Settings, + type LucideIcon, +} from "lucide-react"; + +export interface QuickAction { + id: string; + label: string; + icon: LucideIcon; + href: string; + tip: string; +} + +// Static catalog of every quick-action destination the Overview dashboard can +// jump to. Derived from the panel's real route surface — the sidebar's +// canonical nav list (components/layout/sidebar.tsx) plus the tab- +// parameterized deep links each of those pages actually supports — never an +// invented route. The release/X/video/roadmap approval queues are +// deliberately absent: they already render directly on the Overview page +// itself (see command-center.tsx), so a "quick action" pointing at the page +// the user is already on would be dead weight. +export const QUICK_ACTIONS_REGISTRY: QuickAction[] = [ + { + id: "new-task", + label: "New Task", + icon: Sparkles, + href: "/prompter", + tip: "Chat with Intake to draft and confirm a new task, including MegaTask batches", + }, + { + id: "tasks", + label: "Tasks", + icon: ListTodo, + href: "/tasks", + tip: "Full task list — filter, search, and open any task's detail", + }, + { + id: "kanban", + label: "Kanban", + icon: Kanban, + href: "/kanban", + tip: "Task board grouped by lifecycle status", + }, + { + id: "git-repository", + label: "Repository", + icon: GitBranch, + href: "/git?tab=repository", + tip: "Branches, commits, and diffs across every project workspace", + }, + { + id: "git-sessions", + label: "Work Sessions", + icon: GitBranch, + href: "/git?tab=sessions", + tip: "Active agent work sessions — branch, commits, and PR per task", + }, + { + id: "workstation-products", + label: "Products", + icon: Briefcase, + href: "/workstation?tab=products", + tip: "Products the fleet ships against", + }, + { + id: "workstation-projects", + label: "Projects", + icon: Briefcase, + href: "/workstation?tab=projects", + tip: "Manage repos, git tokens, and per-project settings", + }, + { + id: "social", + label: "Social", + icon: Share2, + href: "/social", + tip: "X and TikTok post queues, plus the video pipeline", + }, + { + id: "knowledge-base", + label: "Knowledge Base", + icon: Database, + href: "/knowledge-base", + tip: "Search the RAG corpus — playbooks, learnings, and vault notes", + }, + { + id: "a2a", + label: "A2A", + icon: Radio, + href: "/a2a", + tip: "Live agent-to-agent message switchboard and history", + }, + { + id: "agents", + label: "Agents", + icon: Bot, + href: "/agents", + tip: "Every agent's live state, spawn controls, and activity stream", + }, + { + id: "journals", + label: "Journals", + icon: BookOpen, + href: "/journals", + tip: "Per-agent reflections and learnings", + }, + { + id: "auditor", + label: "Auditor", + icon: Shield, + href: "/auditor", + tip: "Silent-observer quality flags and findings review queue", + }, + { + id: "metrics-performance", + label: "Metrics", + icon: Activity, + href: "/metrics?tab=performance", + tip: "Task velocity, status counts, agent load, and team health", + }, + { + id: "metrics-delivery", + label: "Delivery Metrics", + icon: Activity, + href: "/metrics?tab=delivery", + tip: "Cycle time, bottlenecks, and rework rate reconstructed from the audit log", + }, + { + id: "metrics-token-usage", + label: "Token Usage", + icon: Activity, + href: "/metrics?tab=token-usage", + tip: "Token spend, cost projections, cache efficiency, and per-session detail", + }, + { + id: "metrics-scorecards", + label: "Scorecards", + icon: Activity, + href: "/metrics?tab=scorecards", + tip: "Per-agent and per-team delivery scorecards", + }, + { + id: "business-goals", + label: "Goals", + icon: Building2, + href: "/business?tab=goals", + tip: "CEO-owned charter — north star, brand voice, objectives, constraints", + }, + { + id: "business-scorecard", + label: "Business Scorecard", + icon: Building2, + href: "/business?tab=scorecard", + tip: "Live delivery, spend, and speed metrics against the charter", + }, + { + id: "business-secretary", + label: "Secretary", + icon: Bot, + href: "/business?tab=secretary", + tip: "Chat with your chief-of-staff and confirm or reject pending directives", + }, + { + id: "business-pitches", + label: "Pitches", + icon: Building2, + href: "/business?tab=pitches", + tip: "Board-authored product pitches awaiting your decision", + }, + { + id: "ai-providers", + label: "AI Providers", + icon: Cpu, + href: "/settings/ai-providers", + tip: "Model routing and per-role provider assignments", + }, + { + id: "settings", + label: "Settings", + icon: Settings, + href: "/settings", + tip: "Feature flags, credentials, and panel preferences", + }, +]; + +const REGISTRY_BY_ID: ReadonlyMap = new Map( + QUICK_ACTIONS_REGISTRY.map((action) => [action.id, action]), +); + +// Curated for the CEO's actual day-to-day workflow — replaces the old +// hardcoded QuickActionsBar (New Task dialog / Spawn Agent / Secretary / +// Journals / Auditor), which never surfaced Tasks, Kanban, Git, or Metrics +// at all despite those being the highest-traffic destinations. +// Includes every destination the legacy QuickActionsBar offered (secretary, +// journals, auditor) — absorbing it must not silently demote any of them out +// of a fresh install's default view. +export const DEFAULT_QUICK_ACTION_IDS: string[] = [ + "new-task", + "tasks", + "kanban", + "git-repository", + "agents", + "a2a", + "business-secretary", + "journals", + "auditor", + "metrics-performance", + "settings", +]; + +/** + * Resolves a stored, ordered id list into real actions, dropping any id that + * no longer exists in the registry (a stale localStorage entry from a since- + * removed action) instead of crashing. Order-preserving. + */ +export function resolveQuickActions(ids: readonly string[]): QuickAction[] { + const resolved: QuickAction[] = []; + for (const id of ids) { + const action = REGISTRY_BY_ID.get(id); + if (action) resolved.push(action); + } + return resolved; +} + +export function isKnownQuickActionId(id: string): boolean { + return REGISTRY_BY_ID.has(id); +} diff --git a/panel/src/store/__tests__/ui-store.test.ts b/panel/src/store/__tests__/ui-store.test.ts new file mode 100644 index 00000000..feec635b --- /dev/null +++ b/panel/src/store/__tests__/ui-store.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { useUIStore } from "../ui-store"; +import { DEFAULT_QUICK_ACTION_IDS } from "@/components/dashboard/quick-actions-registry"; + +describe("ui-store persistence contract", () => { + it("persists quickActionIds through partialize (a key missing here silently stops persisting)", () => { + const partialize = useUIStore.persist.getOptions().partialize; + expect(partialize).toBeDefined(); + const persisted = partialize!(useUIStore.getState()) as Record; + expect(persisted.quickActionIds).toEqual(DEFAULT_QUICK_ACTION_IDS); + }); + + it("reset restores the exact default list after customization", () => { + useUIStore.getState().setQuickActionIds(["tasks"]); + expect(useUIStore.getState().quickActionIds).toEqual(["tasks"]); + useUIStore.getState().resetQuickActionIds(); + expect(useUIStore.getState().quickActionIds).toEqual(DEFAULT_QUICK_ACTION_IDS); + }); +}); diff --git a/panel/src/store/ui-store.ts b/panel/src/store/ui-store.ts index a445a7ca..9cd26808 100644 --- a/panel/src/store/ui-store.ts +++ b/panel/src/store/ui-store.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { Team } from "@/types"; +import { DEFAULT_QUICK_ACTION_IDS } from "@/components/dashboard/quick-actions-registry"; interface UIState { // Sidebar @@ -30,6 +31,12 @@ interface UIState { autoRefresh: boolean; refreshIntervalSeconds: number; + // Quick Actions (Overview dashboard) — ordered list of + // quick-actions-registry ids the CEO has chosen to show. Per-browser only, + // same persisted-preference idiom as everything else in this store; see + // quick-actions-card.tsx for the render/customize side. + quickActionIds: string[]; + // Actions toggleSidebar: () => void; setSidebarCollapsed: (collapsed: boolean) => void; @@ -42,6 +49,10 @@ interface UIState { setSoundEnabled: (enabled: boolean) => void; setAutoRefresh: (enabled: boolean) => void; setRefreshIntervalSeconds: (seconds: number) => void; + + // Quick Actions actions + setQuickActionIds: (ids: string[]) => void; + resetQuickActionIds: () => void; } export const useUIStore = create()( @@ -59,6 +70,9 @@ export const useUIStore = create()( autoRefresh: false, // default-off: never start a background poller unasked refreshIntervalSeconds: 30, + // Quick Actions default + quickActionIds: DEFAULT_QUICK_ACTION_IDS, + toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), @@ -74,6 +88,11 @@ export const useUIStore = create()( setAutoRefresh: (enabled) => set({ autoRefresh: enabled }), setRefreshIntervalSeconds: (seconds) => set({ refreshIntervalSeconds: seconds }), + + // Quick Actions actions + setQuickActionIds: (ids) => set({ quickActionIds: ids }), + resetQuickActionIds: () => + set({ quickActionIds: DEFAULT_QUICK_ACTION_IDS }), }), { name: "roboco-ui-storage", @@ -88,6 +107,8 @@ export const useUIStore = create()( soundEnabled: state.soundEnabled, autoRefresh: state.autoRefresh, refreshIntervalSeconds: state.refreshIntervalSeconds, + // Quick Actions + quickActionIds: state.quickActionIds, }), }, ),