mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: customizable Quick Actions on the Overview dashboard (#557)
* feat(panel): customizable Quick Actions on Overview — registry, defaults, persisted picker * fix(panel): legacy bar actions join the quick-action defaults; empty state; persist-contract test * docs(map): panel entries for this wave --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -55,8 +55,8 @@ vi.mock("../active-blockers-panel", () => ({
|
||||
vi.mock("../recent-activity-feed", () => ({
|
||||
RecentActivityFeed: () => <div>RecentActivityFeedStub</div>,
|
||||
}));
|
||||
vi.mock("../quick-actions-bar", () => ({
|
||||
QuickActionsBar: () => <div>QuickActionsBarStub</div>,
|
||||
vi.mock("../quick-actions-card", () => ({
|
||||
QuickActionsCard: () => <div>QuickActionsCardStub</div>,
|
||||
}));
|
||||
vi.mock("../ceo-approval-queue", () => ({
|
||||
CeoApprovalQueue: () => <div>CeoApprovalQueueStub</div>,
|
||||
@@ -111,7 +111,7 @@ describe("CommandCenter", () => {
|
||||
it("renders every section", () => {
|
||||
render(<CommandCenter />);
|
||||
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(<CommandCenter />);
|
||||
const order = [
|
||||
"QuickActionsBarStub",
|
||||
"QuickActionsCardStub",
|
||||
"KeyMetricsPanelStub",
|
||||
"TeamHealthCardsStub",
|
||||
"CeoApprovalQueueStub",
|
||||
|
||||
@@ -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(<QuickActionsCard />);
|
||||
|
||||
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(<QuickActionsCard />);
|
||||
|
||||
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(<QuickActionsCard />);
|
||||
|
||||
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Customize Quick Actions" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("customize dialog", () => {
|
||||
async function openDialog(user: ReturnType<typeof userEvent.setup>) {
|
||||
render(<QuickActionsCard />);
|
||||
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(<QuickActionsCard />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
</h2>
|
||||
</HelpTip>
|
||||
<QuickActionsBar />
|
||||
<QuickActionsCard />
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2 xl:grid-cols-4">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<CreateTaskDialog />
|
||||
|
||||
<Link href="/agents" prefetch={false}>
|
||||
<HelpTip label="Agents page — view the roster and spawn a new agent run">
|
||||
<Button variant="outline">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Spawn Agent
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</Link>
|
||||
|
||||
<Link href="/prompter" prefetch={false}>
|
||||
<HelpTip label="Chat-based interview to draft and submit a new task">
|
||||
<Button variant="outline">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Task Intake
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</Link>
|
||||
|
||||
<Link href="/business?tab=secretary" prefetch={false}>
|
||||
<HelpTip label="Chat with the Secretary — company state and gated CEO directives">
|
||||
<Button variant="outline">
|
||||
<Bot className="h-4 w-4 mr-2" />
|
||||
Secretary
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</Link>
|
||||
|
||||
<Link href="/journals" prefetch={false}>
|
||||
<HelpTip label="Browse agent journal entries and learnings">
|
||||
<Button variant="outline">
|
||||
<BookOpen className="h-4 w-4 mr-2" />
|
||||
View Journals
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</Link>
|
||||
|
||||
<Link href="/auditor" prefetch={false}>
|
||||
<HelpTip label="The Auditor's flagged issues and reports">
|
||||
<Button variant="outline">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Auditor Report
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{actions.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
No quick actions selected — use the pencil to add some.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid flex-1 grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-2">
|
||||
{actions.map((action) => (
|
||||
<HelpTip key={action.id} label={action.tip}>
|
||||
<Link href={action.href} prefetch={false}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full flex-col gap-1.5 py-3"
|
||||
>
|
||||
<action.icon className="h-5 w-5" />
|
||||
<span className="text-xs font-medium">{action.label}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</HelpTip>
|
||||
))}
|
||||
</div>
|
||||
<QuickActionsCustomizeDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<HelpTip label={CUSTOMIZE_LABEL}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={CUSTOMIZE_LABEL}
|
||||
title={CUSTOMIZE_LABEL}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</HelpTip>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Customize Quick Actions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick which shortcuts show on the Overview dashboard and reorder
|
||||
them with the arrows. Saved to this browser only.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-96 space-y-1 overflow-y-auto">
|
||||
{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 (
|
||||
<div
|
||||
key={id}
|
||||
className="flex items-center gap-2 rounded-md px-1 py-1.5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isEnabled}
|
||||
onCheckedChange={() => toggle(id)}
|
||||
aria-label={`Show ${action.label}`}
|
||||
/>
|
||||
<action.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<HelpTip label={action.tip}>
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{action.label}
|
||||
</span>
|
||||
</HelpTip>
|
||||
{isEnabled && (
|
||||
<div className="flex shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isFirst}
|
||||
onClick={() => move(id, -1)}
|
||||
aria-label={`Move ${action.label} earlier`}
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move earlier</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
disabled={isLast}
|
||||
onClick={() => move(id, 1)}
|
||||
aria-label={`Move ${action.label} later`}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Move later</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetQuickActionIds}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1.5" />
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string, QuickAction> = 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);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<UIState>()(
|
||||
@@ -59,6 +70,9 @@ export const useUIStore = create<UIState>()(
|
||||
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<UIState>()(
|
||||
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<UIState>()(
|
||||
soundEnabled: state.soundEnabled,
|
||||
autoRefresh: state.autoRefresh,
|
||||
refreshIntervalSeconds: state.refreshIntervalSeconds,
|
||||
// Quick Actions
|
||||
quickActionIds: state.quickActionIds,
|
||||
}),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user