diff --git a/ui/src/components/BudgetIncidentCard.tsx b/ui/src/components/BudgetIncidentCard.tsx index 7a24e4a0ea5..49836819cfe 100644 --- a/ui/src/components/BudgetIncidentCard.tsx +++ b/ui/src/components/BudgetIncidentCard.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import type { BudgetIncident } from "@paperclipai/shared"; import { AlertOctagon, ArrowUpRight, PauseCircle } from "lucide-react"; import { formatCents } from "../lib/utils"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -16,6 +17,14 @@ function parseDollarInput(value: string) { return Math.round(parsed * 100); } +function incidentStateLabel(incident: BudgetIncident) { + if (incident.status === "resolved") return "Resolved"; + if (incident.status === "dismissed") return "Dismissed"; + if (incident.approvalStatus === "revision_requested") return "Escalated"; + if (incident.approvalStatus === "pending") return "Pending approval"; + return "Open"; +} + export function BudgetIncidentCard({ incident, onRaiseAndResume, @@ -31,14 +40,20 @@ export function BudgetIncidentCard({ centsInputValue(Math.max(incident.amountObserved + 1000, incident.amountLimit)), ); const parsed = parseDollarInput(draftAmount); + const stateLabel = incidentStateLabel(incident); return (
-
- {incident.scopeType} hard stop +
+
+ {incident.scopeType} hard stop +
+ + {stateLabel} +
{incident.scopeName} diff --git a/ui/storybook/stories/issue-management.stories.tsx b/ui/storybook/stories/issue-management.stories.tsx new file mode 100644 index 00000000000..f46fd926f5d --- /dev/null +++ b/ui/storybook/stories/issue-management.stories.tsx @@ -0,0 +1,601 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Issue } from "@paperclipai/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ArrowDownAZ, + ArrowUpDown, + Check, + Columns3, + Filter, + GitBranch, + LayoutList, + Link2, + PanelRight, + Rows3, +} from "lucide-react"; +import { IssueColumnPicker, InboxIssueMetaLeading, InboxIssueTrailingColumns } from "@/components/IssueColumns"; +import { IssueContinuationHandoff } from "@/components/IssueContinuationHandoff"; +import { IssueDocumentsSection } from "@/components/IssueDocumentsSection"; +import { IssueFiltersPopover } from "@/components/IssueFiltersPopover"; +import { IssueGroupHeader } from "@/components/IssueGroupHeader"; +import { IssueLinkQuicklook, IssueQuicklookCard } from "@/components/IssueLinkQuicklook"; +import { IssueProperties } from "@/components/IssueProperties"; +import { IssueRunLedgerContent } from "@/components/IssueRunLedger"; +import { IssuesList } from "@/components/IssuesList"; +import { IssuesQuicklook } from "@/components/IssuesQuicklook"; +import { IssueWorkspaceCard } from "@/components/IssueWorkspaceCard"; +import { Identity } from "@/components/Identity"; +import { PriorityIcon } from "@/components/PriorityIcon"; +import { StatusBadge } from "@/components/StatusBadge"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { countActiveIssueFilters, defaultIssueFilterState, type IssueFilterState } from "@/lib/issue-filters"; +import { DEFAULT_INBOX_ISSUE_COLUMNS, type InboxIssueColumn } from "@/lib/inbox"; +import { queryKeys } from "@/lib/queryKeys"; +import { + storybookAgentMap, + storybookAgents, + storybookAuthSession, + storybookCompanies, + storybookContinuationHandoff, + storybookExecutionWorkspaces, + storybookIssueDocuments, + storybookIssueLabels, + storybookIssueRuns, + storybookIssues, + storybookProjects, +} from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const issueListViewKey = "storybook:issue-management:list"; +const scopedIssueListViewKey = `${issueListViewKey}:${companyId}`; +const visibleColumns: InboxIssueColumn[] = ["status", "id", "assignee", "project", "workspace", "labels", "updated"]; + +const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary); +const primaryIssue: Issue = { + ...storybookIssues[0]!, + planDocument: storybookIssueDocuments.find((document) => document.key === "plan") ?? null, + documentSummaries: issueDocumentSummaries, + currentExecutionWorkspace: storybookExecutionWorkspaces[0]!, +}; +const childIssues = storybookIssues.filter((issue) => issue.parentId === primaryIssue.id); + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function hydrateStorybookQueries(queryClient: ReturnType) { + queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); + queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); + queryClient.setQueryData(queryKeys.agents.list(companyId), storybookAgents); + queryClient.setQueryData(queryKeys.projects.list(companyId), storybookProjects); + queryClient.setQueryData(queryKeys.issues.list(companyId), storybookIssues); + queryClient.setQueryData(queryKeys.issues.labels(companyId), storybookIssueLabels); + queryClient.setQueryData(queryKeys.issues.documents(primaryIssue.id), storybookIssueDocuments); + queryClient.setQueryData(queryKeys.issues.runs(primaryIssue.id), storybookIssueRuns); + queryClient.setQueryData(queryKeys.issues.liveRuns(primaryIssue.id), []); + queryClient.setQueryData(queryKeys.issues.activeRun(primaryIssue.id), null); + queryClient.setQueryData(queryKeys.instance.experimentalSettings, { + enableIsolatedWorkspaces: true, + enableRoutineTriggers: true, + }); + queryClient.setQueryData(queryKeys.access.companyUserDirectory(companyId), { + users: [ + { + principalId: "user-board", + status: "active", + user: { + id: "user-board", + email: "riley@paperclip.local", + name: "Riley Board", + image: null, + }, + }, + ], + }); + queryClient.setQueryData( + queryKeys.sidebarPreferences.projectOrder(companyId, storybookAuthSession.user.id), + { orderedIds: storybookProjects.map((project) => project.id), updatedAt: null }, + ); + queryClient.setQueryData( + queryKeys.executionWorkspaces.summaryList(companyId), + storybookExecutionWorkspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + mode: workspace.mode, + projectWorkspaceId: workspace.projectWorkspaceId, + })), + ); + queryClient.setQueryData( + queryKeys.executionWorkspaces.list(companyId, { + projectId: primaryIssue.projectId ?? undefined, + projectWorkspaceId: primaryIssue.projectWorkspaceId ?? undefined, + reuseEligible: true, + }), + storybookExecutionWorkspaces, + ); +} + +function seedIssueListLocalStorage() { + if (typeof window === "undefined") return; + window.localStorage.setItem( + scopedIssueListViewKey, + JSON.stringify({ + ...defaultIssueFilterState, + sortField: "priority", + sortDir: "desc", + groupBy: "status", + viewMode: "list", + nestingEnabled: true, + collapsedGroups: [], + collapsedParents: [], + }), + ); + window.localStorage.setItem(`${scopedIssueListViewKey}:issue-columns`, JSON.stringify(visibleColumns)); +} + +function StorybookData({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [ready] = useState(() => { + hydrateStorybookQueries(queryClient); + seedIssueListLocalStorage(); + return true; + }); + + return ready ? children : null; +} + +function ColumnConfigurationMatrix() { + const [columns, setColumns] = useState(visibleColumns); + const visibleColumnSet = useMemo(() => new Set(columns), [columns]); + const triggerRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + triggerRef.current?.querySelector("button")?.click(); + }, 150); + return () => window.clearTimeout(timer); + }, []); + + return ( +
+
+
+ Issue + + Assignee + Project + Workspace + Tags + Updated + +
+ {storybookIssues.slice(0, 3).map((issue) => ( +
+
+ + {issue.title} +
+ !["status", "id"].includes(column))} + projectName={storybookProjects.find((project) => project.id === issue.projectId)?.name ?? null} + projectColor={storybookProjects.find((project) => project.id === issue.projectId)?.color ?? null} + workspaceId={issue.projectWorkspaceId ?? issue.executionWorkspaceId} + workspaceName={issue.currentExecutionWorkspace?.name ?? "Board UI"} + assigneeName={issue.assigneeAgentId ? storybookAgentMap.get(issue.assigneeAgentId)?.name ?? null : null} + assigneeUserName={issue.assigneeUserId ? "Riley Board" : null} + currentUserId="user-board" + parentIdentifier={storybookIssues.find((candidate) => candidate.id === issue.parentId)?.identifier ?? null} + parentTitle={storybookIssues.find((candidate) => candidate.id === issue.parentId)?.title ?? null} + /> +
+ ))} +
+ + + + + + Column configuration + + Open picker plus sort state tokens used beside issue rows. + + +
+ { + setColumns((current) => { + const next = enabled ? [...current, column] : current.filter((value) => value !== column); + return DEFAULT_INBOX_ISSUE_COLUMNS.filter((candidate) => next.includes(candidate)).concat( + next.filter((candidate) => !DEFAULT_INBOX_ISSUE_COLUMNS.includes(candidate)), + ); + }); + }} + onResetColumns={() => setColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} + title="Choose which issue columns stay visible" + /> +
+
+ {[ + { label: "Priority", icon: ArrowUpDown, state: "descending" }, + { label: "Title", icon: ArrowDownAZ, state: "ascending" }, + { label: "Updated", icon: Check, state: "active default" }, + ].map((item) => { + const Icon = item.icon; + return ( +
+ + + {item.label} + + {item.state} +
+ ); + })} +
+
+
+
+ ); +} + +function GroupHeaderMatrix() { + const rows = [ + { label: "In progress", trailing: "1 issue", badge: }, + { label: "High priority", trailing: "3 issues", badge: }, + { label: "CodexCoder", trailing: "3 assigned", badge: }, + ]; + + return ( +
+ {rows.map((row, index) => ( +
+ {row.trailing}} + /> +
{row.badge}
+
+ ))} +
+ ); +} + +function OpenFiltersPopover() { + const [state, setState] = useState({ + ...defaultIssueFilterState, + statuses: ["in_progress", "blocked", "in_review"], + priorities: ["critical", "high"], + assignees: ["agent-codex", "agent-qa", "__unassigned"], + }); + const triggerRef = useRef(null); + + useEffect(() => { + const timer = window.setTimeout(() => { + triggerRef.current?.querySelector("button")?.click(); + }, 150); + return () => window.clearTimeout(timer); + }, []); + + return ( +
+
+ setState((current) => ({ ...current, ...patch }))} + activeFilterCount={countActiveIssueFilters(state, true)} + agents={storybookAgents.map((agent) => ({ id: agent.id, name: agent.name }))} + projects={storybookProjects.map((project) => ({ id: project.id, name: project.name }))} + labels={storybookIssueLabels.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId="user-board" + enableRoutineVisibilityFilter + buttonVariant="outline" + workspaces={storybookExecutionWorkspaces.map((workspace) => ({ id: workspace.id, name: workspace.name }))} + creators={[ + { id: "user:user-board", label: "Riley Board", kind: "user", searchText: "board user human" }, + ...storybookAgents.map((agent) => ({ + id: `agent:${agent.id}`, + label: agent.name, + kind: "agent" as const, + searchText: `${agent.name} ${agent.role}`, + })), + ]} + /> +
+
+ ); +} + +function RunLedgerWithCostColumns() { + return ( +
+ +
+
+ Run + Status + Duration + Cost +
+ {storybookIssueRuns.map((run) => { + const start = run.startedAt ? new Date(run.startedAt).getTime() : null; + const end = run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now(); + const minutes = start ? Math.max(1, Math.round((end - start) / 60_000)) : null; + const costCents = typeof run.usageJson?.costCents === "number" ? run.usageJson.costCents : 0; + return ( +
+ {run.runId} + {run.status} + {minutes ? `${minutes}m` : "unknown"} + ${(costCents / 100).toFixed(2)} +
+ ); + })} +
+
+ ); +} + +function WorkspaceCardWithRuntime() { + const service = primaryIssue.currentExecutionWorkspace?.runtimeServices?.[0] ?? null; + + return ( +
+ undefined} + /> + + + + + Runtime status + + Branch, path, and running service context paired with the workspace card. + + +
+ Branch + {primaryIssue.currentExecutionWorkspace?.branchName} +
+
+ Path +
+ {primaryIssue.currentExecutionWorkspace?.cwd} +
+
+ {service ? ( +
+ {service.serviceName} + {service.status} +
+ ) : null} +
+
+
+ ); +} + +function QuicklookSurfaces() { + return ( +
+
+
+ + IssueLinkQuicklook +
+ + {primaryIssue.identifier} + +
+ +
+
+ +
+
+ + IssuesQuicklook +
+ + + +
+ +
+
+
+ ); +} + +function IssueManagementStories() { + return ( + +
+
+
+
+
+
Issue management
+

List, detail, filters, runs, and workspace states

+

+ Fixture-backed issue management stories cover the operational states used by the board when reviewing, + filtering, handing off, and continuing agent work. +

+
+
+ 7 issues + 3 agents + workspace aware +
+
+
+ +
+
+ Issue + Assignee + Workspace + Updated +
+ undefined} + createIssueLabel="issue" + enableRoutineVisibilityFilter + /> +
+ +
+ +
+ +
+ +
+ +
+
+
+
+ + + {primaryIssue.identifier} +
+

{primaryIssue.title}

+

{primaryIssue.description}

+
+
+ undefined} + onUpdate={() => undefined} + inline + /> +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ {[ + { icon: LayoutList, label: "List density", detail: "Grouped rows keep status and ownership visible." }, + { icon: Filter, label: "Filtering", detail: "Selected filters are explicit and clearable." }, + { icon: Rows3, label: "Detail panels", detail: "Properties, documents, runs, and workspaces stay close to the task." }, + ].map((item) => { + const Icon = item.icon; + return ( + + + + {item.label} + {item.detail} + + + ); + })} +
+
+
+
+ ); +} + +const meta = { + title: "Product/Issue Management", + component: IssueManagementStories, + parameters: { + docs: { + description: { + component: + "Issue-management stories exercise the full list, column, grouping, property, document, filter, continuation, run, workspace, and quicklook surfaces.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FullSurfaceMatrix: Story = {}; diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-layout.stories.tsx new file mode 100644 index 00000000000..89e95105d7b --- /dev/null +++ b/ui/storybook/stories/navigation-layout.stories.tsx @@ -0,0 +1,360 @@ +import { useEffect, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + Bot, + CircleDot, + House, + Inbox, + LayoutDashboard, + SquarePen, + Users, +} from "lucide-react"; +import { BreadcrumbBar } from "@/components/BreadcrumbBar"; +import { CommandPalette } from "@/components/CommandPalette"; +import { CompanyRail } from "@/components/CompanyRail"; +import { CompanySwitcher } from "@/components/CompanySwitcher"; +import { KeyboardShortcutsCheatsheetContent } from "@/components/KeyboardShortcutsCheatsheet"; +import { MobileBottomNav } from "@/components/MobileBottomNav"; +import { PageTabBar } from "@/components/PageTabBar"; +import { Sidebar } from "@/components/Sidebar"; +import { SidebarAccountMenu } from "@/components/SidebarAccountMenu"; +import { SidebarCompanyMenu } from "@/components/SidebarCompanyMenu"; +import { StatusBadge } from "@/components/StatusBadge"; +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { Tabs } from "@/components/ui/tabs"; +import { BreadcrumbProvider, useBreadcrumbs, type Breadcrumb } from "@/context/BreadcrumbContext"; +import { useNavigate } from "@/lib/router"; +import { cn } from "@/lib/utils"; +import { + storybookAgents, + storybookIssues, + storybookProjects, + storybookSidebarBadges, +} from "../fixtures/paperclipData"; + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function RouteSetter({ to }: { to: string }) { + const navigate = useNavigate(); + + useEffect(() => { + navigate(to, { replace: true }); + }, [navigate, to]); + + return null; +} + +function SidebarShell({ collapsed = false }: { collapsed?: boolean }) { + return ( +
+
+ +
+ +
+
+
+ ); +} + +function BreadcrumbScenario({ breadcrumbs }: { breadcrumbs: Breadcrumb[] }) { + const { setBreadcrumbs } = useBreadcrumbs(); + + useEffect(() => { + setBreadcrumbs(breadcrumbs); + }, [breadcrumbs, setBreadcrumbs]); + + return ( +
+ +
+ ); +} + +function BreadcrumbSnapshot({ breadcrumbs }: { breadcrumbs: Breadcrumb[] }) { + return ( + + + + ); +} + +const tabItems = [ + { value: "overview", label: "Overview" }, + { value: "issues", label: "Issues" }, + { value: "runs", label: "Runs" }, + { value: "approvals", label: "Approvals" }, + { value: "budget", label: "Budget" }, + { value: "activity", label: "Activity" }, + { value: "settings", label: "Settings" }, + { value: "history", label: "History" }, +]; + +const mobileNavItems = [ + { label: "Home", icon: House }, + { label: "Issues", icon: CircleDot }, + { label: "Create", icon: SquarePen }, + { label: "Agents", icon: Users }, + { label: "Inbox", icon: Inbox, badge: storybookSidebarBadges.inbox }, +]; + +function MobileBottomNavActiveStateMatrix() { + return ( +
+ {mobileNavItems.map((activeItem) => ( +
+
+ {mobileNavItems.map((item) => { + const Icon = item.icon; + const active = item.label === activeItem.label; + return ( +
+ + + {item.badge ? ( + + {item.badge} + + ) : null} + + {item.label} +
+ ); + })} +
+
+ ))} +
+ ); +} + +function CommandResultsSurface() { + return ( + + + + + + + Create new issue + C + + + + + {storybookIssues.slice(0, 2).map((issue) => ( + + + {issue.identifier} + {issue.title} + + + ))} + + + + {storybookAgents.map((agent) => ( + + + {agent.name} + {agent.role} + + ))} + + + + {storybookProjects.map((project) => ( + + + {project.name} + + ))} + + + + ); +} + +function CommandEmptySurface() { + return ( + + + + No results found. + + + ); +} + +function NavigationLayoutStories() { + return ( +
+ +
+
+
+
+
Navigation and layout
+

Sidebar, command, tabs, and mobile chrome

+

+ Fixture-backed navigation states for the board shell: company switching, dense work navigation, + breadcrumbs, command discovery, and mobile entry points. +

+
+
+ fixture backed + company scoped + responsive chrome +
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+ +
+
+
+
+ undefined} + version="0.3.1" + /> +
+
+ +
+ undefined} /> +
+ +
+ undefined} /> +
+
+
+ +
+
+ + + +
+
+ +
+
+ + + + + + +
+
+ +
+
+
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+
+
+

Keyboard shortcuts

+
+ +
+
+
+
+ ); +} + +const meta = { + title: "Product/Navigation & Layout", + component: NavigationLayoutStories, + parameters: { + docs: { + description: { + component: + "Navigation and layout stories cover the board shell components that orient operators across companies, work surfaces, command search, breadcrumbs, tabs, and mobile navigation.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const BoardChromeMatrix: Story = {};