diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 099c6359ac..2611a0eb39 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -43,6 +43,17 @@ This starts: `pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate. +## Storybook + +The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes. + +```sh +pnpm storybook +pnpm build-storybook +``` + +These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`. + Inspect or stop the current repo's managed dev runner: ```sh diff --git a/package.json b/package.json index ce666bfaf3..21894e98a2 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", + "storybook": "pnpm --filter @paperclipai/ui storybook", + "build-storybook": "pnpm --filter @paperclipai/ui build-storybook", "build": "pnpm run preflight:workspace-links && pnpm -r build", "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", "test": "pnpm run test:run", diff --git a/ui/README.md b/ui/README.md index 0e688669e3..b33021d111 100644 --- a/ui/README.md +++ b/ui/README.md @@ -6,6 +6,15 @@ Published static assets for the Paperclip board UI. The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies. +## Storybook + +Storybook config, stories, and fixtures live under `ui/storybook/`. + +```sh +pnpm --filter @paperclipai/ui storybook +pnpm --filter @paperclipai/ui build-storybook +``` + ## Typical use Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`. diff --git a/ui/package.json b/ui/package.json index 020e2ec2f8..dda6e7154b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,9 +16,11 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "storybook": "storybook dev -p 6006 -c storybook/.storybook", + "build-storybook": "storybook build -c storybook/.storybook -o storybook-static", "preview": "vite preview", "typecheck": "tsc -b", - "clean": "rm -rf dist tsconfig.tsbuildinfo", + "clean": "rm -rf dist storybook-static tsconfig.tsbuildinfo", "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs", "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" }, @@ -61,12 +63,16 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.7", + "@storybook/addon-a11y": "10.3.5", + "@storybook/addon-docs": "10.3.5", + "@storybook/react-vite": "10.3.5", "@types/node": "^25.2.3", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", "tailwindcss": "^4.0.7", "typescript": "^5.7.3", + "storybook": "10.3.5", "vite": "^6.1.0", "vitest": "^3.0.5" } diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1a82d1a613..f495dc0e4d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -40,9 +40,6 @@ import { PluginManager } from "./pages/PluginManager"; import { PluginSettings } from "./pages/PluginSettings"; import { AdapterManager } from "./pages/AdapterManager"; import { PluginPage } from "./pages/PluginPage"; -import { IssueChatUxLab } from "./pages/IssueChatUxLab"; -import { InviteUxLab } from "./pages/InviteUxLab"; -import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; @@ -122,9 +119,6 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> } /> } /> } /> @@ -303,8 +297,6 @@ export function App() { } /> } /> } /> - } /> - } /> }> {boardRoutes()} diff --git a/ui/src/components/BudgetIncidentCard.tsx b/ui/src/components/BudgetIncidentCard.tsx index 7a24e4a0ea..49836819cf 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/src/components/BudgetSidebarMarker.tsx b/ui/src/components/BudgetSidebarMarker.tsx index 43f10b9526..07e5ddfc07 100644 --- a/ui/src/components/BudgetSidebarMarker.tsx +++ b/ui/src/components/BudgetSidebarMarker.tsx @@ -1,11 +1,33 @@ import { DollarSign } from "lucide-react"; -export function BudgetSidebarMarker({ title = "Paused by budget" }: { title?: string }) { +export type BudgetSidebarMarkerLevel = "healthy" | "warning" | "critical"; + +const levelClasses: Record = { + healthy: "bg-emerald-500/90 text-white", + warning: "bg-amber-500/95 text-amber-950", + critical: "bg-red-500/90 text-white", +}; + +const defaultTitles: Record = { + healthy: "Budget healthy", + warning: "Budget warning", + critical: "Paused by budget", +}; + +export function BudgetSidebarMarker({ + title, + level = "critical", +}: { + title?: string; + level?: BudgetSidebarMarkerLevel; +}) { + const accessibleTitle = title ?? defaultTitles[level]; + return ( diff --git a/ui/src/components/CompanySwitcher.tsx b/ui/src/components/CompanySwitcher.tsx index aefe1020f0..2d009dba38 100644 --- a/ui/src/components/CompanySwitcher.tsx +++ b/ui/src/components/CompanySwitcher.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; +import { useState } from "react"; function statusDotColor(status?: string): string { switch (status) { @@ -24,12 +25,20 @@ function statusDotColor(status?: string): string { } } -export function CompanySwitcher() { +interface CompanySwitcherProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function CompanySwitcher({ open: controlledOpen, onOpenChange }: CompanySwitcherProps = {}) { + const [internalOpen, setInternalOpen] = useState(false); const { companies, selectedCompany, setSelectedCompanyId } = useCompany(); const sidebarCompanies = companies.filter((company) => company.status !== "archived"); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; return ( - + + + + + +
+
+ {visibleIcons.map((name) => ( + + ))} +
+
+
+ ); +} + +function AgentActionsMatrix() { + const actionAgents = [ + agentManagementAgents[0]!, + agentManagementAgents[1]!, + agentManagementAgents[2]!, + agentManagementAgents[3]!, + ]; + + return ( +
+ {actionAgents.map((agent) => { + const paused = agent.status === "paused"; + const runDisabled = agent.status === "running" || agent.status === "paused"; + const restartDisabled = agent.status === "paused"; + + return ( + + +
+
+ + + +
+ {agent.name} + {agent.title} +
+
+ {agent.status} +
+
+ + undefined} + onResume={() => undefined} + disabled={agent.status === "running"} + /> + undefined} + disabled={runDisabled} + /> + + + +
+ ); + })} +
+ ); +} + +function ConfigPrimitivesStory() { + const [textValue, setTextValue] = useState("gpt-5.4"); + const [selectValue, setSelectValue] = useState("git_worktree"); + const [toggleValue, setToggleValue] = useState(true); + const [jsonValue, setJsonValue] = useState(JSON.stringify({ + runtimeServices: [ + { name: "api", command: "pnpm dev:once", healthUrl: "http://localhost:3100/api/health" }, + ], + env: { PAPERCLIP_BIND: "lan" }, + }, null, 2)); + + return ( +
+
+ + + + + + + +
+
+ + + +
+
+ ); +} + +function AgentManagementStories() { + return ( + +
+
+
+
+
+
Agent management
+

Agent details, controls, and config surfaces

+

+ Management stories exercise the dense pieces of the agent lifecycle: status detail panels, + adapter configuration, icon identity, run controls, live-agent cards, and the config-field primitives + used inside the form. +

+
+
+ adapter config + runtime policy + env bindings +
+
+
+ +
+
+ + +
+ + + +
+ {agentManagementAgents[0]!.name} + {agentManagementAgents[0]!.capabilities} +
+
+
+ + + +
+
+
+ session populated + last error shown + manager lookup seeded +
+
+
+
Budget
+
${(agentManagementAgents[0]!.budgetMonthlyCents / 100).toFixed(0)} / month
+
+
+
Spent
+
${(agentManagementAgents[0]!.spentMonthlyCents / 100).toFixed(0)}
+
+
+
Instructions
+
+ {String(agentManagementAgents[0]!.adapterConfig.instructionsFilePath)} +
+
+
+
Runtime policy
+
heartbeat / 900s / max 2
+
+
+
+
+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +} + +const meta = { + title: "Product/Agent Management", + component: AgentManagementStories, + parameters: { + docs: { + description: { + component: + "Agent management stories cover detail, configuration, icon, action, live-run, and config primitive states using extended Paperclip fixtures.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ManagementMatrix: Story = {}; diff --git a/ui/storybook/stories/budget-finance.stories.tsx b/ui/storybook/stories/budget-finance.stories.tsx new file mode 100644 index 0000000000..f47ff68aa1 --- /dev/null +++ b/ui/storybook/stories/budget-finance.stories.tsx @@ -0,0 +1,774 @@ +import type { ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { + BudgetIncident, + CostByBiller, + CostByProviderModel, + CostWindowSpendRow, + FinanceByBiller, + FinanceByKind, + FinanceEvent, + QuotaWindow, +} from "@paperclipai/shared"; +import { AlertTriangle, CheckCircle2, CreditCard, Landmark, ReceiptText, WalletCards } from "lucide-react"; +import { AccountingModelCard } from "@/components/AccountingModelCard"; +import { BillerSpendCard } from "@/components/BillerSpendCard"; +import { BudgetIncidentCard } from "@/components/BudgetIncidentCard"; +import { BudgetSidebarMarker, type BudgetSidebarMarkerLevel } from "@/components/BudgetSidebarMarker"; +import { ClaudeSubscriptionPanel } from "@/components/ClaudeSubscriptionPanel"; +import { CodexSubscriptionPanel } from "@/components/CodexSubscriptionPanel"; +import { FinanceBillerCard } from "@/components/FinanceBillerCard"; +import { FinanceKindCard } from "@/components/FinanceKindCard"; +import { FinanceTimelineCard } from "@/components/FinanceTimelineCard"; +import { ProviderQuotaCard } from "@/components/ProviderQuotaCard"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +const now = new Date("2026-04-20T12:00:00.000Z"); +const windowStart = new Date("2026-04-01T00:00:00.000Z"); +const windowEnd = new Date("2026-05-01T00:00:00.000Z"); +const at = (minutesAgo: number) => new Date(now.getTime() - minutesAgo * 60_000); + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function CaseFrame({ + title, + detail, + tone, + children, +}: { + title: string; + detail: string; + tone: "healthy" | "warning" | "critical"; + children: ReactNode; +}) { + const toneClasses = { + healthy: "border-emerald-500/30 bg-emerald-500/5 text-emerald-500", + warning: "border-amber-500/30 bg-amber-500/5 text-amber-500", + critical: "border-red-500/30 bg-red-500/5 text-red-500", + } satisfies Record; + + return ( +
+
+
{title}
+
{detail}
+
+ {children} +
+ ); +} + +const budgetIncidents: BudgetIncident[] = [ + { + id: "incident-agent-resolved", + companyId: "company-storybook", + policyId: "budget-agent-codex", + scopeType: "agent", + scopeId: "agent-codex", + scopeName: "CodexCoder", + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart, + windowEnd, + thresholdType: "hard", + amountLimit: 40_000, + amountObserved: 42_450, + status: "resolved", + approvalId: "approval-budget-resolved", + approvalStatus: "approved", + resolvedAt: at(42), + createdAt: at(180), + updatedAt: at(42), + }, + { + id: "incident-project-pending", + companyId: "company-storybook", + policyId: "budget-project-app", + scopeType: "project", + scopeId: "project-board-ui", + scopeName: "Paperclip App", + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart, + windowEnd, + thresholdType: "hard", + amountLimit: 120_000, + amountObserved: 131_400, + status: "open", + approvalId: "approval-budget-pending", + approvalStatus: "pending", + resolvedAt: null, + createdAt: at(32), + updatedAt: at(8), + }, + { + id: "incident-company-escalated", + companyId: "company-storybook", + policyId: "budget-company", + scopeType: "company", + scopeId: "company-storybook", + scopeName: "Paperclip Storybook", + metric: "billed_cents", + windowKind: "calendar_month_utc", + windowStart, + windowEnd, + thresholdType: "hard", + amountLimit: 250_000, + amountObserved: 287_300, + status: "open", + approvalId: "approval-budget-escalated", + approvalStatus: "revision_requested", + resolvedAt: null, + createdAt: at(14), + updatedAt: at(2), + }, +]; + +const providerRowsByProvider: Record = { + anthropic: [ + { + provider: "anthropic", + biller: "anthropic", + billingType: "subscription_included", + model: "claude-sonnet-4.5", + costCents: 0, + inputTokens: 1_420_000, + cachedInputTokens: 210_000, + outputTokens: 385_000, + apiRunCount: 0, + subscriptionRunCount: 38, + subscriptionCachedInputTokens: 210_000, + subscriptionInputTokens: 1_420_000, + subscriptionOutputTokens: 385_000, + }, + { + provider: "anthropic", + biller: "anthropic", + billingType: "metered_api", + model: "claude-opus-4.5", + costCents: 11_240, + inputTokens: 280_000, + cachedInputTokens: 35_000, + outputTokens: 92_000, + apiRunCount: 7, + subscriptionRunCount: 0, + subscriptionCachedInputTokens: 0, + subscriptionInputTokens: 0, + subscriptionOutputTokens: 0, + }, + ], + openai: [ + { + provider: "openai", + biller: "openai", + billingType: "subscription_included", + model: "gpt-5.4-codex", + costCents: 0, + inputTokens: 1_050_000, + cachedInputTokens: 164_000, + outputTokens: 318_000, + apiRunCount: 0, + subscriptionRunCount: 26, + subscriptionCachedInputTokens: 164_000, + subscriptionInputTokens: 1_050_000, + subscriptionOutputTokens: 318_000, + }, + { + provider: "openai", + biller: "openai", + billingType: "subscription_overage", + model: "gpt-5.3-codex-spark", + costCents: 18_900, + inputTokens: 620_000, + cachedInputTokens: 91_000, + outputTokens: 250_000, + apiRunCount: 9, + subscriptionRunCount: 12, + subscriptionCachedInputTokens: 91_000, + subscriptionInputTokens: 410_000, + subscriptionOutputTokens: 160_000, + }, + ], + openrouter: [ + { + provider: "anthropic", + biller: "openrouter", + billingType: "credits", + model: "anthropic/claude-sonnet-4.5", + costCents: 22_640, + inputTokens: 760_000, + cachedInputTokens: 120_000, + outputTokens: 220_000, + apiRunCount: 19, + subscriptionRunCount: 0, + subscriptionCachedInputTokens: 0, + subscriptionInputTokens: 0, + subscriptionOutputTokens: 0, + }, + { + provider: "google", + biller: "openrouter", + billingType: "credits", + model: "google/gemini-3-pro", + costCents: 8_920, + inputTokens: 430_000, + cachedInputTokens: 44_000, + outputTokens: 118_000, + apiRunCount: 11, + subscriptionRunCount: 0, + subscriptionCachedInputTokens: 0, + subscriptionInputTokens: 0, + subscriptionOutputTokens: 0, + }, + ], +}; + +const providerWindowRows: Record = { + anthropic: [ + { provider: "anthropic", biller: "anthropic", window: "5h", windowHours: 5, costCents: 1_240, inputTokens: 82_000, cachedInputTokens: 11_000, outputTokens: 19_000 }, + { provider: "anthropic", biller: "anthropic", window: "24h", windowHours: 24, costCents: 3_870, inputTokens: 218_000, cachedInputTokens: 32_000, outputTokens: 64_000 }, + { provider: "anthropic", biller: "anthropic", window: "7d", windowHours: 168, costCents: 11_240, inputTokens: 1_700_000, cachedInputTokens: 245_000, outputTokens: 477_000 }, + ], + openai: [ + { provider: "openai", biller: "openai", window: "5h", windowHours: 5, costCents: 4_920, inputTokens: 148_000, cachedInputTokens: 18_000, outputTokens: 56_000 }, + { provider: "openai", biller: "openai", window: "24h", windowHours: 24, costCents: 10_430, inputTokens: 398_000, cachedInputTokens: 52_000, outputTokens: 130_000 }, + { provider: "openai", biller: "openai", window: "7d", windowHours: 168, costCents: 18_900, inputTokens: 1_670_000, cachedInputTokens: 255_000, outputTokens: 568_000 }, + ], + openrouter: [ + { provider: "openrouter", biller: "openrouter", window: "5h", windowHours: 5, costCents: 7_880, inputTokens: 210_000, cachedInputTokens: 20_000, outputTokens: 73_000 }, + { provider: "openrouter", biller: "openrouter", window: "24h", windowHours: 24, costCents: 14_630, inputTokens: 506_000, cachedInputTokens: 51_000, outputTokens: 150_000 }, + { provider: "openrouter", biller: "openrouter", window: "7d", windowHours: 168, costCents: 31_560, inputTokens: 1_190_000, cachedInputTokens: 164_000, outputTokens: 338_000 }, + ], +}; + +const claudeQuotaWindows: QuotaWindow[] = [ + { label: "Current session", usedPercent: 46, resetsAt: at(-180).toISOString(), valueLabel: null, detail: "Healthy session headroom for review tasks." }, + { label: "Current week all models", usedPercent: 74, resetsAt: at(-5_300).toISOString(), valueLabel: null, detail: "Warning threshold after the release documentation run." }, + { label: "Current week Opus only", usedPercent: 92, resetsAt: at(-5_300).toISOString(), valueLabel: null, detail: "Critical model-specific budget: route default work to Sonnet." }, + { label: "Extra usage", usedPercent: null, resetsAt: null, valueLabel: "$18.40 overage", detail: "Overage billing is enabled for board-approved release checks." }, +]; + +const codexQuotaWindows: QuotaWindow[] = [ + { label: "5h limit", usedPercent: 38, resetsAt: at(-92).toISOString(), valueLabel: null, detail: "Healthy short-window capacity." }, + { label: "Weekly limit", usedPercent: 83, resetsAt: at(-4_720).toISOString(), valueLabel: null, detail: "Warning: schedule high-context follow-ups after reset." }, + { label: "Credits", usedPercent: null, resetsAt: null, valueLabel: "$61.25 remaining", detail: "Credit balance after subscription-covered runs." }, + { label: "GPT-5.3 Codex Spark weekly limit", usedPercent: 96, resetsAt: at(-4_720).toISOString(), valueLabel: null, detail: "Critical model window for Spark-heavy story generation." }, +]; + +const billerSpendRows: Array<{ + state: "healthy" | "warning" | "critical"; + row: CostByBiller; + providerRows: CostByProviderModel[]; + totalCompanySpendCents: number; + weekSpendCents: number; +}> = [ + { + state: "healthy", + row: { + biller: "anthropic", + costCents: 11_240, + inputTokens: 1_700_000, + cachedInputTokens: 245_000, + outputTokens: 477_000, + apiRunCount: 7, + subscriptionRunCount: 38, + subscriptionCachedInputTokens: 210_000, + subscriptionInputTokens: 1_420_000, + subscriptionOutputTokens: 385_000, + providerCount: 1, + modelCount: 2, + }, + providerRows: providerRowsByProvider.anthropic, + totalCompanySpendCents: 83_000, + weekSpendCents: 3_870, + }, + { + state: "warning", + row: { + biller: "openai", + costCents: 18_900, + inputTokens: 1_670_000, + cachedInputTokens: 255_000, + outputTokens: 568_000, + apiRunCount: 9, + subscriptionRunCount: 38, + subscriptionCachedInputTokens: 255_000, + subscriptionInputTokens: 1_460_000, + subscriptionOutputTokens: 478_000, + providerCount: 1, + modelCount: 2, + }, + providerRows: providerRowsByProvider.openai, + totalCompanySpendCents: 218_000, + weekSpendCents: 10_430, + }, + { + state: "critical", + row: { + biller: "openrouter", + costCents: 31_560, + inputTokens: 1_190_000, + cachedInputTokens: 164_000, + outputTokens: 338_000, + apiRunCount: 30, + subscriptionRunCount: 0, + subscriptionCachedInputTokens: 0, + subscriptionInputTokens: 0, + subscriptionOutputTokens: 0, + providerCount: 2, + modelCount: 2, + }, + providerRows: providerRowsByProvider.openrouter, + totalCompanySpendCents: 286_000, + weekSpendCents: 14_630, + }, +]; + +const financeBillerRows: FinanceByBiller[] = [ + { + biller: "openai", + debitCents: 74_200, + creditCents: 12_000, + netCents: 62_200, + estimatedDebitCents: 18_400, + eventCount: 7, + kindCount: 3, + }, + { + biller: "aws_bedrock", + debitCents: 45_880, + creditCents: 0, + netCents: 45_880, + estimatedDebitCents: 45_880, + eventCount: 4, + kindCount: 2, + }, +]; + +const financeKindRows: FinanceByKind[] = [ + { + eventKind: "inference_charge", + debitCents: 49_820, + creditCents: 0, + netCents: 49_820, + estimatedDebitCents: 12_700, + eventCount: 9, + billerCount: 3, + }, + { + eventKind: "log_storage_charge", + debitCents: 8_760, + creditCents: 0, + netCents: 8_760, + estimatedDebitCents: 8_760, + eventCount: 3, + billerCount: 1, + }, + { + eventKind: "provisioned_capacity_charge", + debitCents: 42_900, + creditCents: 0, + netCents: 42_900, + estimatedDebitCents: 42_900, + eventCount: 2, + billerCount: 1, + }, + { + eventKind: "credit_refund", + debitCents: 0, + creditCents: 12_000, + netCents: -12_000, + estimatedDebitCents: 0, + eventCount: 1, + billerCount: 1, + }, +]; + +const financeTimelineRows: FinanceEvent[] = [ + { + id: "finance-event-openai-invoice", + companyId: "company-storybook", + agentId: null, + issueId: null, + projectId: "project-board-ui", + goalId: "goal-company", + heartbeatRunId: null, + costEventId: null, + billingCode: "product", + description: "Monthly ChatGPT/Codex business plan charge for engineering agents.", + eventKind: "platform_fee", + direction: "debit", + biller: "openai", + provider: "openai", + executionAdapterType: "codex_local", + pricingTier: "business", + region: "us", + model: null, + quantity: 8, + unit: "request", + amountCents: 40_000, + currency: "USD", + estimated: false, + externalInvoiceId: "INV-2026-04-OPENAI-1184", + metadataJson: { paymentMethod: "corporate-card" }, + occurredAt: at(1_260), + createdAt: at(1_255), + }, + { + id: "finance-event-bedrock-compute", + companyId: "company-storybook", + agentId: "agent-codex", + issueId: "issue-storybook-1", + projectId: "project-board-ui", + goalId: "goal-company", + heartbeatRunId: "run-storybook", + costEventId: null, + billingCode: "product", + description: "Provisioned Bedrock capacity for release smoke testing.", + eventKind: "provisioned_capacity_charge", + direction: "debit", + biller: "aws_bedrock", + provider: "anthropic", + executionAdapterType: "claude_local", + pricingTier: "provisioned", + region: "us-east-1", + model: "claude-sonnet-4.5", + quantity: 14, + unit: "model_unit_hour", + amountCents: 42_900, + currency: "USD", + estimated: true, + externalInvoiceId: "AWS-EST-7713", + metadataJson: { purchaseOrder: "PO-STORYBOOK-APR" }, + occurredAt: at(420), + createdAt: at(416), + }, + { + id: "finance-event-log-storage", + companyId: "company-storybook", + agentId: null, + issueId: null, + projectId: "project-observability", + goalId: "goal-company", + heartbeatRunId: null, + costEventId: null, + billingCode: "ops", + description: "Log retention and audit bundle storage.", + eventKind: "log_storage_charge", + direction: "debit", + biller: "cloudflare", + provider: "cloudflare", + executionAdapterType: null, + pricingTier: "standard", + region: "global", + model: null, + quantity: 312, + unit: "gb_month", + amountCents: 8_760, + currency: "USD", + estimated: true, + externalInvoiceId: "CF-APR-2026-0091", + metadataJson: null, + occurredAt: at(210), + createdAt: at(205), + }, + { + id: "finance-event-credit-refund", + companyId: "company-storybook", + agentId: null, + issueId: null, + projectId: null, + goalId: "goal-company", + heartbeatRunId: null, + costEventId: null, + billingCode: "finance", + description: "Credit refund after duplicate OpenAI top-up.", + eventKind: "credit_refund", + direction: "credit", + biller: "openai", + provider: "openai", + executionAdapterType: null, + pricingTier: null, + region: null, + model: null, + quantity: 120, + unit: "credit_usd", + amountCents: 12_000, + currency: "USD", + estimated: false, + externalInvoiceId: "CR-2026-04-OPENAI-041", + metadataJson: null, + occurredAt: at(64), + createdAt: at(61), + }, +]; + +const sidebarMarkers: Array<{ + level: BudgetSidebarMarkerLevel; + label: string; + detail: string; + icon: typeof CheckCircle2; +}> = [ + { + level: "healthy", + label: "Healthy", + detail: "27% of company budget used", + icon: CheckCircle2, + }, + { + level: "warning", + label: "Warning", + detail: "86% of project budget used", + icon: AlertTriangle, + }, + { + level: "critical", + label: "Critical", + detail: "Agent paused by hard stop", + icon: WalletCards, + }, +]; + +function BudgetFinanceMatrix() { + return ( +
+
+
+
+
+
Budget and finance
+

Spend controls, quotas, and accounting surfaces

+

+ Fixture-backed coverage for the board's cost-control components: active incidents, sidebar budget markers, + provider quotas, biller allocation, account-level finance events, and subscription quota windows. +

+
+
+ healthy + warning + critical +
+
+
+ +
+
+ + undefined} + onRaiseAndResume={() => undefined} + /> + + + undefined} + onRaiseAndResume={() => undefined} + /> + + + undefined} + onRaiseAndResume={() => undefined} + /> + +
+
+ +
+
+ {sidebarMarkers.map((marker) => { + const Icon = marker.icon; + return ( +
+
+ +
+
{marker.label}
+
{marker.detail}
+
+ +
+
+ ); + })} +
+
+ +
+
+ + + + + + + + + +
+
+ +
+ +
+ +
+
+ {billerSpendRows.map((entry) => ( + + + + ))} +
+
+ +
+
+
+
+ {financeBillerRows.map((row) => ( + + ))} +
+ +
+
+ + + + + + Category fixtures + + Compute, storage, and API-style finance rows are represented by provisioned capacity, log storage, and inference charges. + + + {[ + { label: "Compute", value: "$429.00", icon: Landmark }, + { label: "Storage", value: "$87.60", icon: CreditCard }, + { label: "API", value: "$498.20", icon: WalletCards }, + ].map((item) => { + const Icon = item.icon; + return ( +
+ + + {item.label} + + {item.value} +
+ ); + })} +
+
+
+
+
+ +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +const meta = { + title: "Product/Budget & Finance", + component: BudgetFinanceMatrix, + parameters: { + docs: { + description: { + component: + "Budget and finance stories cover incident resolution states, sidebar markers, provider and biller quotas, finance ledgers, and Claude/Codex subscription panels.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FullMatrix: Story = {}; diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx new file mode 100644 index 0000000000..620b96c881 --- /dev/null +++ b/ui/storybook/stories/chat-comments.stories.tsx @@ -0,0 +1,713 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent, FeedbackVote, IssueComment } from "@paperclipai/shared"; +import type { TranscriptEntry } from "@/adapters"; +import type { LiveRunForIssue } from "@/api/heartbeats"; +import { CommentThread } from "@/components/CommentThread"; +import { IssueChatThread } from "@/components/IssueChatThread"; +import { RunChatSurface } from "@/components/RunChatSurface"; +import type { InlineEntityOption } from "@/components/InlineEntitySelector"; +import type { MentionOption } from "@/components/MarkdownEditor"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import type { + IssueChatComment, + IssueChatLinkedRun, + IssueChatTranscriptEntry, +} from "@/lib/issue-chat-messages"; +import type { IssueTimelineEvent } from "@/lib/issue-timeline-events"; +import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const projectId = "project-board-ui"; +const issueId = "issue-chat-comments"; +const currentUserId = "user-board"; + +type StoryComment = IssueComment & { + runId?: string | null; + runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; +}; + +const codexAgent = storybookAgents.find((agent) => agent.id === "agent-codex") ?? storybookAgents[0]!; +const qaAgent = storybookAgents.find((agent) => agent.id === "agent-qa") ?? storybookAgents[1]!; +const ctoAgent = storybookAgents.find((agent) => agent.id === "agent-cto") ?? storybookAgents[2]!; + +const boardUserLabels = new Map([ + ["user-board", "Riley Board"], + ["user-product", "Mara Product"], +]); + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
+
{eyebrow}
+

{title}

+
+
+
{children}
+
+ ); +} + +function ScenarioCard({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( + + + {title} + {description} + + {children} + + ); +} + +function createComment(overrides: Partial): StoryComment { + const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z"); + return { + id: "comment-default", + companyId, + issueId, + authorAgentId: null, + authorUserId: currentUserId, + body: "", + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + ...overrides, + }; +} + +function createSystemEvent(overrides: Partial): IssueTimelineEvent { + return { + id: "event-default", + createdAt: new Date("2026-04-20T14:00:00.000Z"), + actorType: "system", + actorId: "paperclip", + statusChange: { + from: "todo", + to: "in_progress", + }, + ...overrides, + }; +} + +const mentionOptions: MentionOption[] = [ + { + id: `agent:${codexAgent.id}`, + name: codexAgent.name, + kind: "agent", + agentId: codexAgent.id, + agentIcon: codexAgent.icon, + }, + { + id: `agent:${qaAgent.id}`, + name: qaAgent.name, + kind: "agent", + agentId: qaAgent.id, + agentIcon: qaAgent.icon, + }, + { + id: `project:${projectId}`, + name: "Board UI", + kind: "project", + projectId, + projectColor: "#0f766e", + }, +]; + +const reassignOptions: InlineEntityOption[] = [ + { + id: `agent:${codexAgent.id}`, + label: codexAgent.name, + searchText: `${codexAgent.name} engineer codex`, + }, + { + id: `agent:${qaAgent.id}`, + label: qaAgent.name, + searchText: `${qaAgent.name} qa browser review`, + }, + { + id: `agent:${ctoAgent.id}`, + label: ctoAgent.name, + searchText: `${ctoAgent.name} architecture review`, + }, + { + id: `user:${currentUserId}`, + label: "Riley Board", + searchText: "board operator", + }, +]; + +const singleComment = [ + createComment({ + id: "comment-single-board", + body: "Please make the issue chat states reviewable in Storybook before the next UI pass.", + createdAt: new Date("2026-04-20T13:12:00.000Z"), + }), +]; + +const longThreadComments = [ + createComment({ + id: "comment-long-board", + body: "The chat surface should show the operator request first, then agent progress, then review follow-up. Keep the density close to the issue page.", + createdAt: new Date("2026-04-20T13:02:00.000Z"), + }), + createComment({ + id: "comment-long-agent", + authorAgentId: codexAgent.id, + authorUserId: null, + body: "I found the existing `IssueChatThread` and `RunChatSurface` components and am building the stories around those props.", + createdAt: new Date("2026-04-20T13:08:00.000Z"), + runId: "run-comment-thread-01", + runAgentId: codexAgent.id, + }), + createComment({ + id: "comment-long-product", + authorUserId: "user-product", + body: "Also include the old comment timeline so we can compare it with the assistant-style issue chat.", + createdAt: new Date("2026-04-20T13:16:00.000Z"), + }), + createComment({ + id: "comment-long-qa", + authorAgentId: qaAgent.id, + authorUserId: null, + body: "QA note: the thread should stay readable with long markdown and when a queued operator reply is visible.", + createdAt: new Date("2026-04-20T13:24:00.000Z"), + runId: "run-comment-thread-02", + runAgentId: qaAgent.id, + }), +]; + +const markdownComments = [ + createComment({ + id: "comment-markdown-board", + body: [ + "Acceptance criteria:", + "", + "- Cover empty, single, and long comment states", + "- Show a code block in a comment", + "- Include a link to [the issue guide](/issues/PAP-1676)", + "", + "```ts", + "const success = stories.some((story) => story.includes(\"IssueChatThread\"));", + "```", + ].join("\n"), + createdAt: new Date("2026-04-20T13:28:00.000Z"), + }), + createComment({ + id: "comment-mentions-agent", + authorAgentId: codexAgent.id, + authorUserId: null, + body: "@QAChecker I added the fixture coverage. Please focus browser review on links, code blocks, and the queued comment treatment.", + createdAt: new Date("2026-04-20T13:35:00.000Z"), + runId: "run-markdown-01", + runAgentId: codexAgent.id, + }), +]; + +const queuedComment = createComment({ + id: "comment-queued-board", + body: "@CodexCoder after this run finishes, add a compact embedded variant too.", + createdAt: new Date("2026-04-20T13:39:00.000Z"), + clientId: "client-queued-storybook", + clientStatus: "queued", + queueState: "queued", + queueTargetRunId: "run-live-chat-01", +}); + +const commentTimelineEvents: IssueTimelineEvent[] = [ + createSystemEvent({ + id: "event-system-checkout", + createdAt: new Date("2026-04-20T13:04:00.000Z"), + actorType: "system", + actorId: "paperclip", + statusChange: { + from: "todo", + to: "in_progress", + }, + }), + createSystemEvent({ + id: "event-board-reassign", + createdAt: new Date("2026-04-20T13:18:00.000Z"), + actorType: "user", + actorId: currentUserId, + assigneeChange: { + from: { agentId: codexAgent.id, userId: null }, + to: { agentId: qaAgent.id, userId: null }, + }, + statusChange: undefined, + }), +]; + +const commentLinkedRuns = [ + { + runId: "run-comment-thread-01", + status: "succeeded", + agentId: codexAgent.id, + createdAt: new Date("2026-04-20T13:07:00.000Z"), + startedAt: new Date("2026-04-20T13:07:00.000Z"), + finishedAt: new Date("2026-04-20T13:11:00.000Z"), + }, + { + runId: "run-comment-thread-02", + status: "running", + agentId: qaAgent.id, + createdAt: new Date("2026-04-20T13:22:00.000Z"), + startedAt: new Date("2026-04-20T13:22:00.000Z"), + finishedAt: null, + }, +]; + +const feedbackVotes: FeedbackVote[] = [ + { + id: "feedback-chat-comment-01", + companyId, + issueId, + targetType: "issue_comment", + targetId: "comment-issue-agent", + authorUserId: currentUserId, + vote: "up", + reason: null, + sharedWithLabs: false, + sharedAt: null, + consentVersion: null, + redactionSummary: null, + createdAt: new Date("2026-04-20T13:52:00.000Z"), + updatedAt: new Date("2026-04-20T13:52:00.000Z"), + }, +]; + +const liveRun: LiveRunForIssue = { + id: "run-live-chat-01", + status: "running", + invocationSource: "manual", + triggerDetail: "comment", + createdAt: "2026-04-20T13:40:00.000Z", + startedAt: "2026-04-20T13:40:02.000Z", + finishedAt: null, + agentId: codexAgent.id, + agentName: codexAgent.name, + adapterType: "codex_local", + issueId, +}; + +const liveRunTranscript: TranscriptEntry[] = [ + { + kind: "assistant", + ts: "2026-04-20T13:40:08.000Z", + text: "I am wiring the chat and comments Storybook coverage now.", + }, + { + kind: "thinking", + ts: "2026-04-20T13:40:12.000Z", + text: "Need fixtures that exercise MarkdownBody, assistant-ui messages, and the embedded run transcript path without reaching the API.", + }, + { + kind: "tool_call", + ts: "2026-04-20T13:40:18.000Z", + name: "rg", + toolUseId: "tool-live-rg", + input: { + query: "IssueChatThread", + cwd: "ui/src", + }, + }, + { + kind: "tool_result", + ts: "2026-04-20T13:40:20.000Z", + toolUseId: "tool-live-rg", + content: "ui/src/components/IssueChatThread.tsx\nui/src/components/RunChatSurface.tsx", + isError: false, + }, + { + kind: "assistant", + ts: "2026-04-20T13:40:31.000Z", + text: [ + "The live run should render code blocks as part of the assistant response:", + "", + "```tsx", + "", + "```", + ].join("\n"), + }, + { + kind: "tool_call", + ts: "2026-04-20T13:40:44.000Z", + name: "apply_patch", + toolUseId: "tool-live-patch", + input: { + file: "ui/storybook/stories/chat-comments.stories.tsx", + action: "add fixtures", + }, + }, + { + kind: "tool_result", + ts: "2026-04-20T13:40:49.000Z", + toolUseId: "tool-live-patch", + content: "Added Storybook scenarios for comment thread, run chat, and issue chat.", + isError: false, + }, +]; + +const issueChatComments: IssueChatComment[] = [ + createComment({ + id: "comment-issue-board", + body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.", + createdAt: new Date("2026-04-20T13:44:00.000Z"), + }), + createComment({ + id: "comment-issue-agent", + authorAgentId: codexAgent.id, + authorUserId: null, + body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.", + createdAt: new Date("2026-04-20T13:50:00.000Z"), + runId: "run-issue-chat-01", + runAgentId: codexAgent.id, + }), + createComment({ + id: "comment-issue-queued", + body: "@QAChecker please do a quick visual pass after the Storybook build is green.", + createdAt: new Date("2026-04-20T13:56:00.000Z"), + clientId: "client-issue-queued", + clientStatus: "queued", + queueState: "queued", + queueTargetRunId: liveRun.id, + }), +]; + +const issueTimelineEvents: IssueTimelineEvent[] = [ + createSystemEvent({ + id: "event-issue-checkout", + createdAt: new Date("2026-04-20T13:42:00.000Z"), + actorType: "system", + actorId: "paperclip", + statusChange: { + from: "todo", + to: "in_progress", + }, + }), + createSystemEvent({ + id: "event-issue-assignee", + createdAt: new Date("2026-04-20T13:43:00.000Z"), + actorType: "user", + actorId: currentUserId, + statusChange: undefined, + assigneeChange: { + from: { agentId: null, userId: null }, + to: { agentId: codexAgent.id, userId: null }, + }, + }), +]; + +const issueLinkedRuns: IssueChatLinkedRun[] = [ + { + runId: "run-issue-chat-01", + status: "succeeded", + agentId: codexAgent.id, + agentName: codexAgent.name, + adapterType: "codex_local", + createdAt: new Date("2026-04-20T13:46:00.000Z"), + startedAt: new Date("2026-04-20T13:46:00.000Z"), + finishedAt: new Date("2026-04-20T13:51:00.000Z"), + hasStoredOutput: true, + }, +]; + +const issueTranscriptsByRunId = new Map([ + [ + "run-issue-chat-01", + [ + { + kind: "thinking", + ts: "2026-04-20T13:46:10.000Z", + text: "Checking the existing Storybook organization before adding a new product group.", + }, + { + kind: "tool_call", + ts: "2026-04-20T13:46:16.000Z", + name: "read_file", + toolUseId: "tool-issue-read", + input: { + path: "ui/storybook/stories/overview.stories.tsx", + }, + }, + { + kind: "tool_result", + ts: "2026-04-20T13:46:19.000Z", + toolUseId: "tool-issue-read", + content: "The coverage map already lists Chat & comments as a planned section.", + isError: false, + }, + { + kind: "assistant", + ts: "2026-04-20T13:49:00.000Z", + text: "Added the story file and kept every fixture local to the story so product data fixtures stay stable.", + }, + { + kind: "diff", + ts: "2026-04-20T13:49:04.000Z", + changeType: "file_header", + text: "diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx", + }, + { + kind: "diff", + ts: "2026-04-20T13:49:05.000Z", + changeType: "add", + text: "+export const FullSurfaceMatrix: Story = {};", + }, + ], + ], + [liveRun.id, liveRunTranscript], +]); + +function ThreadProps({ + comments, + queuedComments = [], + timelineEvents = [], +}: { + comments: StoryComment[]; + queuedComments?: StoryComment[]; + timelineEvents?: IssueTimelineEvent[]; +}) { + return ( + {}} + enableReassign + reassignOptions={reassignOptions} + currentAssigneeValue={`agent:${codexAgent.id}`} + suggestedAssigneeValue={`agent:${codexAgent.id}`} + mentions={mentionOptions} + onInterruptQueued={async () => {}} + /> + ); +} + +function CommentThreadMatrix() { + return ( +
+
+ + + + + + + + + + + + +
+
+ ); +} + +function RunChatMatrix() { + return ( +
+
+
+ +
+ + + Run fixture shape + Streaming transcript entries mixed into the same chat renderer used by issue chat. + + +
+ Status + running +
+
+ Tool calls + rg, apply_patch +
+
+ Transcript entries + {liveRunTranscript.length} +
+
+
+
+
+ ); +} + +function IssueChatMatrix() { + return ( +
+
+
+ {}} + onVote={async () => {}} + onStopRun={async () => {}} + enableReassign + reassignOptions={reassignOptions} + currentAssigneeValue={`agent:${codexAgent.id}`} + suggestedAssigneeValue={`agent:${codexAgent.id}`} + mentions={mentionOptions} + enableLiveTranscriptPolling={false} + transcriptsByRunId={issueTranscriptsByRunId} + hasOutputForRun={(runId) => issueTranscriptsByRunId.has(runId)} + includeSucceededRunsWithoutOutput + onInterruptQueued={async () => {}} + onCancelQueued={() => undefined} + /> +
+
+ + {}} + enableLiveTranscriptPolling={false} + emptyMessage="No chat yet. The first operator note will start the issue conversation." + /> + + + {}} + showJumpToLatest={false} + enableLiveTranscriptPolling={false} + composerDisabledReason="This issue is in review. Request changes or approve it from the review controls." + /> + +
+
+
+ ); +} + +function ChatCommentsStories() { + return ( +
+
+
+
Chat & Comments
+

Threaded work conversations

+

+ Fixture-backed coverage for classic issue comments, embedded run chat, and the assistant-style issue chat + surface. The scenarios use Paperclip operational content with mixed authors, system timeline events, + markdown, code blocks, @mentions, links, queued comments, tool calls, and streaming run output. +

+
+ + + + +
+
+ ); +} + +const meta = { + title: "Product/Chat & Comments", + component: ChatCommentsStories, + parameters: { + docs: { + description: { + component: + "Chat and comments stories exercise CommentThread, RunChatSurface, and IssueChatThread across empty, single, long, markdown, mention, timeline, queued, linked-run, and streaming transcript states.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const FullSurfaceMatrix: Story = {}; + +export const CommentThreads: Story = { + render: () => ( +
+
+ +
+
+ ), +}; + +export const LiveRunChat: Story = { + render: () => ( +
+
+ +
+
+ ), +}; + +export const IssueChatWithTimeline: Story = { + render: () => ( +
+
+ +
+
+ ), +}; diff --git a/ui/storybook/stories/control-plane-surfaces.stories.tsx b/ui/storybook/stories/control-plane-surfaces.stories.tsx new file mode 100644 index 0000000000..98e4a1fc5c --- /dev/null +++ b/ui/storybook/stories/control-plane-surfaces.stories.tsx @@ -0,0 +1,266 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AlertTriangle, CheckCircle2, Clock3, Eye, GitPullRequest, Inbox, WalletCards } from "lucide-react"; +import { ActivityRow } from "@/components/ActivityRow"; +import { ApprovalCard } from "@/components/ApprovalCard"; +import { BudgetPolicyCard } from "@/components/BudgetPolicyCard"; +import { Identity } from "@/components/Identity"; +import { IssueRow } from "@/components/IssueRow"; +import { PriorityIcon } from "@/components/PriorityIcon"; +import { StatusBadge } from "@/components/StatusBadge"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + storybookActivityEvents, + storybookAgentMap, + storybookAgents, + storybookApprovals, + storybookBudgetSummaries, + storybookEntityNameMap, + storybookEntityTitleMap, + storybookIssues, +} from "../fixtures/paperclipData"; + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function ControlPlaneSurfaces() { + return ( +
+
+
+
+
+
Product surfaces
+

Control-plane boards and cards

+

+ Paperclip's common board surfaces are deliberately dense: task rows, approvals, budget policy cards, + and audit rows all need to scan quickly while preserving enough state to make autonomous work governable. +

+
+
+ company scoped + single assignee + auditable +
+
+
+ +
+
+ {storybookIssues.map((issue, index) => ( + undefined} + onArchive={() => undefined} + desktopTrailing={ + + + {issue.assigneeAgentId ? ( + + ) : ( + Board + )} + + } + trailingMeta={index === 0 ? "3m ago" : index === 1 ? "blocked by budget" : "review requested"} + mobileMeta={} + titleSuffix={ + index === 0 ? ( + + Storybook + + ) : null + } + /> + ))} +
+
+ +
+
+ {storybookApprovals.map((approval) => ( + undefined : undefined} + onReject={approval.status === "pending" ? () => undefined : undefined} + detailLink={`/approvals/${approval.id}`} + /> + ))} +
+
+ +
+
+ {storybookBudgetSummaries.map((summary, index) => ( + undefined : undefined} + /> + ))} +
+
+ undefined} + /> +
+
+ +
+
+
+ {storybookActivityEvents.map((event) => ( + + ))} +
+ + + + Run summary card language + Compact status treatments used around live work. + + + {[ + { icon: Clock3, label: "Running", detail: "CodexCoder is editing Storybook fixtures", tone: "text-cyan-600" }, + { icon: GitPullRequest, label: "Review", detail: "QAChecker requested browser screenshots", tone: "text-amber-600" }, + { icon: CheckCircle2, label: "Verified", detail: "Vitest and static Storybook build passed", tone: "text-emerald-600" }, + { icon: AlertTriangle, label: "Blocked", detail: "Budget hard stop paused a run", tone: "text-red-600" }, + ].map((item) => { + const Icon = item.icon; + return ( +
+ +
+
{item.label}
+
{item.detail}
+
+
+ ); + })} +
+
+
+
+ +
+
+ {storybookAgents.map((agent) => ( + + +
+ + +
+ {agent.title} +
+ +

{agent.capabilities}

+
+ {agent.role} + {agent.adapterType} + + + ${(agent.spentMonthlyCents / 100).toFixed(0)} spent + +
+
+
+ ))} +
+
+ +
+
+ + + + + Inbox slice + + Small panels should keep controls reachable without nested cards. + + +
+ Unread + 7 +
+
+ Needs review + 3 +
+
+ Blocked + 1 +
+
+
+
+
+ + Review target +
+ undefined} + desktopTrailing={} + trailingMeta="active run" + /> +
+
+
+
+
+ ); +} + +const meta = { + title: "Product/Control Plane Surfaces", + component: ControlPlaneSurfaces, + parameters: { + docs: { + description: { + component: + "Product-surface stories exercise the board UI components that carry Paperclip's task, approval, budget, activity, and agent governance workflows.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const BoardStateMatrix: Story = {}; diff --git a/ui/storybook/stories/data-viz-misc.stories.tsx b/ui/storybook/stories/data-viz-misc.stories.tsx new file mode 100644 index 0000000000..2022b3cd43 --- /dev/null +++ b/ui/storybook/stories/data-viz-misc.stories.tsx @@ -0,0 +1,757 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { HeartbeatRun, Issue } from "@paperclipai/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Archive, + Bot, + CheckCircle2, + Clock3, + FileCode2, + FolderKanban, + ListFilter, + Loader2, + Play, + ShieldCheck, + Sparkles, +} from "lucide-react"; +import { + ChartCard, + IssueStatusChart, + PriorityChart, + RunActivityChart, + SuccessRateChart, +} from "@/components/ActivityCharts"; +import { AsciiArtAnimation } from "@/components/AsciiArtAnimation"; +import { CompanyPatternIcon } from "@/components/CompanyPatternIcon"; +import { EntityRow } from "@/components/EntityRow"; +import { FilterBar, type FilterValue } from "@/components/FilterBar"; +import { KanbanBoard } from "@/components/KanbanBoard"; +import { LiveRunWidget } from "@/components/LiveRunWidget"; +import { OnboardingWizard } from "@/components/OnboardingWizard"; +import { + buildFileTree, + collectAllPaths, + countFiles, + PackageFileTree, + parseFrontmatter, + type FileTreeNode, +} from "@/components/PackageFileTree"; +import { PageSkeleton } from "@/components/PageSkeleton"; +import { StatusBadge } from "@/components/StatusBadge"; +import { SwipeToArchive } from "@/components/SwipeToArchive"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useDialog } from "@/context/DialogContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + createIssue, + storybookAgents, + storybookIssues, + storybookLiveRuns, +} from "../fixtures/paperclipData"; + +const companyId = "company-storybook"; +const primaryIssueId = "issue-storybook-1"; + +function StoryShell({ children }: { children: React.ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function daysAgo(days: number, hour = 12): Date { + const date = new Date(); + date.setHours(hour, 0, 0, 0); + date.setDate(date.getDate() - days); + return date; +} + +function makeHeartbeatRun(overrides: Partial): HeartbeatRun { + const createdAt = overrides.createdAt ?? daysAgo(1); + const run: HeartbeatRun = { + id: "run-fixture", + companyId, + agentId: "agent-codex", + invocationSource: "on_demand", + triggerDetail: "manual", + status: "succeeded", + startedAt: createdAt, + finishedAt: new Date(createdAt.getTime() + 11 * 60_000), + error: null, + wakeupRequestId: null, + exitCode: 0, + signal: null, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: 0, + logSha256: null, + logCompressed: false, + stdoutExcerpt: null, + stderrExcerpt: null, + errorCode: null, + externalRunId: null, + processPid: null, + processGroupId: null, + processStartedAt: createdAt, + retryOfRunId: null, + processLossRetryCount: 0, + livenessState: "completed", + livenessReason: null, + continuationAttempt: 0, + lastUsefulActionAt: null, + nextAction: null, + contextSnapshot: null, + ...overrides, + createdAt, + updatedAt: overrides.updatedAt ?? createdAt, + }; + return run; +} + +const activityRuns: HeartbeatRun[] = [ + makeHeartbeatRun({ id: "run-chart-1", status: "succeeded", createdAt: daysAgo(13), startedAt: daysAgo(13) }), + makeHeartbeatRun({ id: "run-chart-2", status: "succeeded", createdAt: daysAgo(10), startedAt: daysAgo(10) }), + makeHeartbeatRun({ id: "run-chart-3", status: "failed", createdAt: daysAgo(10), startedAt: daysAgo(10, 15), exitCode: 1 }), + makeHeartbeatRun({ id: "run-chart-4", status: "running", createdAt: daysAgo(7), startedAt: daysAgo(7), finishedAt: null }), + makeHeartbeatRun({ id: "run-chart-5", status: "succeeded", createdAt: daysAgo(5), startedAt: daysAgo(5) }), + makeHeartbeatRun({ id: "run-chart-6", status: "timed_out", createdAt: daysAgo(3), startedAt: daysAgo(3), errorCode: "timeout" }), + makeHeartbeatRun({ id: "run-chart-7", status: "succeeded", createdAt: daysAgo(1), startedAt: daysAgo(1) }), + makeHeartbeatRun({ id: "run-chart-8", status: "succeeded", createdAt: daysAgo(1, 16), startedAt: daysAgo(1, 16) }), +]; + +const activityIssues = [ + { priority: "high", status: "in_progress", createdAt: daysAgo(13) }, + { priority: "critical", status: "blocked", createdAt: daysAgo(11) }, + { priority: "medium", status: "todo", createdAt: daysAgo(9) }, + { priority: "medium", status: "in_review", createdAt: daysAgo(9, 16) }, + { priority: "low", status: "done", createdAt: daysAgo(6) }, + { priority: "high", status: "todo", createdAt: daysAgo(4) }, + { priority: "critical", status: "in_progress", createdAt: daysAgo(2) }, + { priority: "medium", status: "done", createdAt: daysAgo(1) }, +]; + +const kanbanIssues: Issue[] = [ + ...storybookIssues, + createIssue({ + id: "issue-kanban-backlog", + identifier: "PAP-1701", + issueNumber: 1701, + title: "Sketch company analytics dashboard", + status: "backlog", + priority: "low", + assigneeAgentId: "agent-cto", + }), + createIssue({ + id: "issue-kanban-cancelled", + identifier: "PAP-1702", + issueNumber: 1702, + title: "Remove obsolete color token migration", + status: "cancelled", + priority: "medium", + assigneeAgentId: null, + }), +]; + +const packageFiles: Record = { + "COMPANY.md": "---\nname: Paperclip Storybook\nkind: company\n---\nFixture company package for UI review.", + "agents/codexcoder/AGENTS.md": "---\nname: CodexCoder\nskills:\n - frontend-design\n - paperclip\n---\nShips product UI and verifies changes.", + "agents/qachecker/AGENTS.md": "---\nname: QAChecker\nskills:\n - web-design-guidelines\n---\nReviews browser behavior and acceptance criteria.", + "projects/board-ui/PROJECT.md": "---\ntitle: Board UI\nstatus: in_progress\n---\nStorybook and operator control-plane surfaces.", + "tasks/PAP-1641.md": "---\ntitle: Create super-detailed storybooks\npriority: high\n---\nParent issue for Storybook coverage.", + "tasks/PAP-1677.md": "---\ntitle: Data Visualization & Misc stories\npriority: medium\n---\nFixture task for this story file.", + "skills/frontend-design/SKILL.md": "---\nname: frontend-design\n---\nDesign quality guidance.", +}; + +const actionMap = new Map([ + ["COMPANY.md", "replace"], + ["agents/codexcoder/AGENTS.md", "update"], + ["agents/qachecker/AGENTS.md", "create"], + ["tasks/PAP-1677.md", "create"], +]); + +function ActivityChartsMatrix({ empty = false }: { empty?: boolean }) { + const runs = empty ? [] : activityRuns; + const issues = empty ? [] : activityIssues; + + return ( + +
+
+ + + + + + + + + + + + +
+
+
+ ); +} + +function KanbanBoardDemo({ empty = false }: { empty?: boolean }) { + const [issues, setIssues] = useState(empty ? [] : kanbanIssues); + const liveIssueIds = useMemo(() => new Set(["issue-storybook-1", "issue-kanban-backlog"]), []); + + return ( + +
+ { + setIssues((current) => + current.map((issue) => (issue.id === id ? { ...issue, ...data } : issue)), + ); + }} + /> +
+
+ ); +} + +function FilterBarDemo({ empty = false }: { empty?: boolean }) { + const [filters, setFilters] = useState( + empty + ? [] + : [ + { key: "status", label: "Status", value: "In progress" }, + { key: "assignee", label: "Assignee", value: "CodexCoder" }, + { key: "priority", label: "Priority", value: "High" }, + { key: "project", label: "Project", value: "Board UI" }, + ], + ); + + return ( + +
+
+ setFilters((current) => current.filter((filter) => filter.key !== key))} + onClear={() => setFilters([])} + /> + {filters.length === 0 && ( +
+ + No filters are active. +
+ )} +
+
+
+ ); +} + +function LiveRunWidgetStory({ empty = false, loading = false }: { empty?: boolean; loading?: boolean }) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (loading) return; + queryClient.setQueryData(queryKeys.issues.liveRuns(primaryIssueId), empty ? [] : storybookLiveRuns); + queryClient.setQueryData(queryKeys.issues.activeRun(primaryIssueId), empty ? null : storybookLiveRuns[0]); + }, [empty, loading, queryClient]); + + if (loading) { + return ( + +
+
+ + Waiting for the first run poll. +
+
+
+ ); + } + + return ( + +
+ + {empty && ( +
+ + The widget renders no panel when the issue has no live runs. +
+ )} +
+
+ ); +} + +function OpenOnboardingOnMount({ initialStep }: { initialStep: 1 | 2 }) { + const { openOnboarding } = useDialog(); + const queryClient = useQueryClient(); + + useEffect(() => { + queryClient.setQueryData(queryKeys.agents.adapterModels(companyId, "claude_local"), [ + { id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, + { id: "claude-opus-4-1", label: "Claude Opus 4.1" }, + ]); + openOnboarding(initialStep === 1 ? { initialStep } : { initialStep, companyId }); + }, [initialStep, openOnboarding, queryClient]); + + return ; +} + +function PackageFileTreeDemo({ empty = false }: { empty?: boolean }) { + const nodes = useMemo( + () => (empty ? [] : buildFileTree(packageFiles, actionMap)), + [empty], + ); + const allFilePaths = useMemo(() => collectAllPaths(nodes, "file"), [nodes]); + const [expandedDirs, setExpandedDirs] = useState(() => collectAllPaths(nodes, "dir")); + const [checkedFiles, setCheckedFiles] = useState(() => allFilePaths); + const [selectedFile, setSelectedFile] = useState(empty ? null : "tasks/PAP-1677.md"); + + useEffect(() => { + setExpandedDirs(collectAllPaths(nodes, "dir")); + setCheckedFiles(allFilePaths); + setSelectedFile(empty ? null : "tasks/PAP-1677.md"); + }, [allFilePaths, empty, nodes]); + + const selectedContent = selectedFile ? packageFiles[selectedFile] ?? "" : ""; + const frontmatter = selectedContent ? parseFrontmatter(selectedContent) : null; + + function toggleDir(path: string) { + setExpandedDirs((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + } + + function toggleCheck(path: string, kind: "file" | "dir") { + setCheckedFiles((current) => { + const next = new Set(current); + const paths = + kind === "file" + ? [path] + : [...collectAllPaths(findNode(nodes, path)?.children ?? [], "file")]; + const shouldCheck = paths.some((candidate) => !next.has(candidate)); + for (const candidate of paths) { + if (shouldCheck) next.add(candidate); + else next.delete(candidate); + } + return next; + }); + } + + return ( + +
+ {empty ? ( +
+ No files are included in this package preview. +
+ ) : ( +
+
+
+ Package contents + {countFiles(nodes)} files +
+ + node.action ? ( + + {node.action} + + ) : null + } + /> +
+ + + + + {selectedFile} + + Frontmatter and markdown body parsed from the selected package file. + + + {frontmatter ? ( +
+ {Object.entries(frontmatter.data).map(([key, value]) => ( +
+
{key}
+
{Array.isArray(value) ? value.join(", ") : value}
+
+ ))} +
+ ) : null} +
+                  {frontmatter?.body.trim() || selectedContent}
+                
+
+
+
+ )} +
+
+ ); +} + +function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | null { + for (const node of nodes) { + if (node.path === path) return node; + const child = findNode(node.children, path); + if (child) return child; + } + return null; +} + +function EntityRowsDemo({ empty = false }: { empty?: boolean }) { + const rows = empty + ? [] + : [ + { + id: "agent", + leading: , + identifier: "agent", + title: "CodexCoder", + subtitle: "Senior Product Engineer · active in Storybook worktree", + trailing: , + selected: true, + }, + { + id: "issue", + leading: , + identifier: "PAP-1677", + title: "Storybook: Data Visualization & Misc stories", + subtitle: "Medium priority · Board UI project", + trailing: UI, + }, + { + id: "approval", + leading: , + identifier: "approval", + title: "Publish Storybook preview", + subtitle: "Approved for internal design review", + trailing: , + }, + ]; + + return ( + +
+
+ {rows.map((row) => ( + + ))} + {rows.length === 0 && ( +
No entities match this view.
+ )} +
+
+
+ ); +} + +function SwipeToArchiveDemo({ disabled = false }: { disabled?: boolean }) { + const [archived, setArchived] = useState(false); + + return ( + +
+
+
+ Inbox +
+ {archived ? ( +
+ + Archived +
+ ) : ( + setArchived(true)} + > + } + identifier="PAP-1677" + title="Storybook: Data Visualization & Misc stories" + subtitle={disabled ? "Gesture disabled while review is locked" : "Swipe left on touch devices to archive"} + trailing={mobile} + /> + + )} + +
+
+
+ ); +} + +function CompanyPatternIconMatrix() { + const companies = [ + { name: "Paperclip Storybook", color: "#0f766e" }, + { name: "Research Bureau", color: "#2563eb" }, + { name: "Launch Ops", color: "#c2410c" }, + { name: "Atlas Finance", color: "#7c3aed" }, + ]; + const sizes = ["h-8 w-8 text-xs", "h-11 w-11 text-base", "h-16 w-16 text-xl", "h-24 w-24 text-3xl"]; + + return ( + +
+
+ {companies.map((company) => ( + + + {company.name} + {company.color} + + + {sizes.map((size) => ( + + ))} + + + ))} +
+
+
+ ); +} + +function AsciiArtAnimationDemo({ loading = false }: { loading?: boolean }) { + return ( + +
+
+ {loading ? ( +
+ + Preparing animation canvas +
+ ) : ( + + )} +
+
+
+ ); +} + +function PageSkeletonMatrix() { + const variants = [ + "list", + "issues-list", + "detail", + "dashboard", + "approvals", + "costs", + "inbox", + "org-chart", + ] as const; + + return ( + +
+
+ {variants.map((variant) => ( + + + {variant} + + +
+ +
+
+
+ ))} +
+
+
+ ); +} + +const meta = { + title: "Product/Data Visualization & Misc", + parameters: { + docs: { + description: { + component: + "Fixture-backed stories for charting, board, filtering, live run, onboarding, package preview, entity row, mobile gesture, generated icon, ASCII animation, and skeleton states.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ActivityChartsPopulated: Story = { + name: "ActivityCharts / Populated", + render: () => , +}; + +export const ActivityChartsEmpty: Story = { + name: "ActivityCharts / Empty", + render: () => , +}; + +export const KanbanBoardPopulated: Story = { + name: "KanbanBoard / Populated", + render: () => , +}; + +export const KanbanBoardEmpty: Story = { + name: "KanbanBoard / Empty", + render: () => , +}; + +export const FilterBarPopulated: Story = { + name: "FilterBar / Populated", + render: () => , +}; + +export const FilterBarEmpty: Story = { + name: "FilterBar / Empty", + render: () => , +}; + +export const LiveRunWidgetPopulated: Story = { + name: "LiveRunWidget / Populated", + render: () => , +}; + +export const LiveRunWidgetLoading: Story = { + name: "LiveRunWidget / Loading", + render: () => , +}; + +export const LiveRunWidgetEmpty: Story = { + name: "LiveRunWidget / Empty", + render: () => , +}; + +export const OnboardingWizardCompanyStep: Story = { + name: "OnboardingWizard / Company Step", + render: () => , +}; + +export const OnboardingWizardAgentStep: Story = { + name: "OnboardingWizard / Agent Step", + render: () => , +}; + +export const PackageFileTreePopulated: Story = { + name: "PackageFileTree / Populated", + render: () => , +}; + +export const PackageFileTreeEmpty: Story = { + name: "PackageFileTree / Empty", + render: () => , +}; + +export const EntityRowPopulated: Story = { + name: "EntityRow / Populated", + render: () => , +}; + +export const EntityRowEmpty: Story = { + name: "EntityRow / Empty", + render: () => , +}; + +export const SwipeToArchiveMobile: Story = { + name: "SwipeToArchive / Mobile", + render: () => , +}; + +export const SwipeToArchiveDisabled: Story = { + name: "SwipeToArchive / Disabled", + render: () => , +}; + +export const CompanyPatternIconSizes: Story = { + name: "CompanyPatternIcon / Sizes", + render: () => , +}; + +export const AsciiArtAnimationPopulated: Story = { + name: "AsciiArtAnimation / Populated", + render: () => , +}; + +export const AsciiArtAnimationLoading: Story = { + name: "AsciiArtAnimation / Loading", + render: () => , +}; + +export const PageSkeletonLayouts: Story = { + name: "PageSkeleton / Layouts", + render: () => , +}; diff --git a/ui/storybook/stories/dialogs-modals.stories.tsx b/ui/storybook/stories/dialogs-modals.stories.tsx new file mode 100644 index 0000000000..4a2cc720b5 --- /dev/null +++ b/ui/storybook/stories/dialogs-modals.stories.tsx @@ -0,0 +1,836 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { + DocumentRevision, + ExecutionWorkspaceCloseReadiness, + Goal, + IssueAttachment, +} from "@paperclipai/shared"; +import { useQueryClient } from "@tanstack/react-query"; +import { Badge } from "@/components/ui/badge"; +import { DocumentDiffModal } from "@/components/DocumentDiffModal"; +import { ExecutionWorkspaceCloseDialog } from "@/components/ExecutionWorkspaceCloseDialog"; +import { ImageGalleryModal } from "@/components/ImageGalleryModal"; +import { NewAgentDialog } from "@/components/NewAgentDialog"; +import { NewGoalDialog } from "@/components/NewGoalDialog"; +import { NewIssueDialog } from "@/components/NewIssueDialog"; +import { NewProjectDialog } from "@/components/NewProjectDialog"; +import { PathInstructionsModal } from "@/components/PathInstructionsModal"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialog } from "@/context/DialogContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { + storybookAgents, + storybookAuthSession, + storybookCompanies, + storybookExecutionWorkspaces, + storybookIssueDocuments, + storybookIssueLabels, + storybookIssues, + storybookProjects, +} from "../fixtures/paperclipData"; + +const COMPANY_ID = "company-storybook"; +const SELECTED_COMPANY_STORAGE_KEY = "paperclip.selectedCompanyId"; +const ISSUE_DRAFT_STORAGE_KEY = "paperclip:issue-draft"; + +const storybookGoals: Goal[] = [ + { + id: "goal-company", + companyId: COMPANY_ID, + title: "Build Paperclip", + description: "Make autonomous companies easier to run and govern.", + level: "company", + status: "active", + parentId: null, + ownerAgentId: "agent-cto", + createdAt: new Date("2026-04-01T09:00:00.000Z"), + updatedAt: new Date("2026-04-20T11:00:00.000Z"), + }, + { + id: "goal-storybook", + companyId: COMPANY_ID, + title: "Complete Storybook coverage", + description: "Expose dense board UI states for review before release.", + level: "team", + status: "active", + parentId: "goal-company", + ownerAgentId: "agent-codex", + createdAt: new Date("2026-04-17T09:00:00.000Z"), + updatedAt: new Date("2026-04-20T11:10:00.000Z"), + }, + { + id: "goal-governance", + companyId: COMPANY_ID, + title: "Tighten governance review", + description: "Make review and approval gates visible in every operator flow.", + level: "team", + status: "planned", + parentId: "goal-company", + ownerAgentId: "agent-cto", + createdAt: new Date("2026-04-18T09:00:00.000Z"), + updatedAt: new Date("2026-04-20T11:15:00.000Z"), + }, +]; + +const documentRevisions: DocumentRevision[] = [ + { + id: "revision-plan-1", + companyId: COMPANY_ID, + documentId: "document-plan-storybook", + issueId: "issue-storybook-1", + key: "plan", + revisionNumber: 1, + title: "Plan", + format: "markdown", + body: [ + "# Plan", + "", + "- Add overview stories for the dashboard.", + "- Create issue list stories for filters and grouping.", + "- Ask QA to review the final Storybook build.", + ].join("\n"), + changeSummary: "Initial plan", + createdByAgentId: "agent-codex", + createdByUserId: null, + createdAt: new Date("2026-04-20T08:00:00.000Z"), + }, + { + id: "revision-plan-2", + companyId: COMPANY_ID, + documentId: "document-plan-storybook", + issueId: "issue-storybook-1", + key: "plan", + revisionNumber: 2, + title: "Plan", + format: "markdown", + body: [ + "# Plan", + "", + "- Add overview stories for the dashboard.", + "- Create issue list stories for filters, grouping, and workspace state.", + "- Add dialog stories for issue, goal, project, and workspace workflows.", + "- Ask QA to review the final Storybook build.", + ].join("\n"), + changeSummary: "Expanded component coverage", + createdByAgentId: "agent-codex", + createdByUserId: null, + createdAt: new Date("2026-04-20T10:00:00.000Z"), + }, + { + id: "revision-plan-3", + companyId: COMPANY_ID, + documentId: "document-plan-storybook", + issueId: "issue-storybook-1", + key: "plan", + revisionNumber: 3, + title: "Plan", + format: "markdown", + body: storybookIssueDocuments[0]?.body ?? "", + changeSummary: "Aligned with current issue scope", + createdByAgentId: "agent-codex", + createdByUserId: null, + createdAt: new Date("2026-04-20T11:30:00.000Z"), + }, +]; + +const closeReadinessReady: ExecutionWorkspaceCloseReadiness = { + workspaceId: "execution-workspace-storybook", + state: "ready_with_warnings", + blockingReasons: [], + warnings: [ + "The branch is still two commits ahead of master.", + "One shared runtime service will be stopped during cleanup.", + ], + linkedIssues: [ + { + id: "issue-storybook-1", + identifier: "PAP-1641", + title: "Create super-detailed storybooks for the project", + status: "done", + isTerminal: true, + }, + { + id: "issue-storybook-6", + identifier: "PAP-1670", + title: "Publish static Storybook preview", + status: "todo", + isTerminal: false, + }, + ], + plannedActions: [ + { + kind: "stop_runtime_services", + label: "Stop Storybook preview", + description: "Stops the managed Storybook preview service before archiving the workspace record.", + command: "pnpm dev:stop", + }, + { + kind: "git_worktree_remove", + label: "Remove git worktree", + description: "Removes the issue worktree from the local worktree parent directory.", + command: "git worktree remove .paperclip/worktrees/PAP-1641-create-super-detailed-storybooks-for-our-project", + }, + { + kind: "archive_record", + label: "Archive workspace record", + description: "Keeps audit history while removing the workspace from active workspace views.", + command: null, + }, + ], + isDestructiveCloseAllowed: true, + isSharedWorkspace: false, + isProjectPrimaryWorkspace: false, + git: { + repoRoot: "/Users/dotta/paperclip", + workspacePath: "/Users/dotta/paperclip/.paperclip/worktrees/PAP-1641-create-super-detailed-storybooks-for-our-project", + branchName: "PAP-1641-create-super-detailed-storybooks-for-our-project", + baseRef: "master", + hasDirtyTrackedFiles: true, + hasUntrackedFiles: false, + dirtyEntryCount: 3, + untrackedEntryCount: 0, + aheadCount: 2, + behindCount: 0, + isMergedIntoBase: false, + createdByRuntime: true, + }, + runtimeServices: storybookExecutionWorkspaces[0]?.runtimeServices ?? [], +}; + +const closeReadinessBlocked: ExecutionWorkspaceCloseReadiness = { + ...closeReadinessReady, + state: "blocked", + blockingReasons: [ + "PAP-1670 is still open and references this execution workspace.", + "The worktree has dirty tracked files that have not been committed.", + ], + warnings: [], + plannedActions: closeReadinessReady.plannedActions.slice(0, 1), +}; + +const galleryImages: IssueAttachment[] = [ + { + id: "attachment-storybook-dashboard", + companyId: COMPANY_ID, + issueId: "issue-storybook-1", + issueCommentId: null, + assetId: "asset-dashboard", + provider: "storybook", + objectKey: "storybook/dashboard-preview.svg", + contentType: "image/svg+xml", + byteSize: 1480, + sha256: "storybook-dashboard-preview", + originalFilename: "dashboard-preview.png", + createdByAgentId: "agent-codex", + createdByUserId: null, + createdAt: new Date("2026-04-20T10:30:00.000Z"), + updatedAt: new Date("2026-04-20T10:30:00.000Z"), + contentPath: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1400' height='900' viewBox='0 0 1400 900'%3E%3Crect width='1400' height='900' fill='%230f172a'/%3E%3Crect x='88' y='96' width='1224' height='708' rx='28' fill='%23111827' stroke='%23334155' stroke-width='4'/%3E%3Crect x='136' y='148' width='264' height='604' rx='18' fill='%231e293b'/%3E%3Crect x='444' y='148' width='380' height='190' rx='18' fill='%230f766e'/%3E%3Crect x='860' y='148' width='348' height='190' rx='18' fill='%232563eb'/%3E%3Crect x='444' y='382' width='764' height='104' rx='18' fill='%23334155'/%3E%3Crect x='444' y='526' width='764' height='104' rx='18' fill='%23334155'/%3E%3Crect x='444' y='670' width='520' height='82' rx='18' fill='%23334155'/%3E%3Ccircle cx='236' cy='236' r='58' fill='%2314b8a6'/%3E%3Crect x='188' y='334' width='164' height='18' rx='9' fill='%2394a3b8'/%3E%3Crect x='188' y='386' width='128' height='18' rx='9' fill='%2364748b'/%3E%3Crect x='188' y='438' width='176' height='18' rx='9' fill='%2364748b'/%3E%3C/svg%3E", + }, + { + id: "attachment-storybook-diff", + companyId: COMPANY_ID, + issueId: "issue-storybook-1", + issueCommentId: null, + assetId: "asset-diff", + provider: "storybook", + objectKey: "storybook/diff-preview.svg", + contentType: "image/svg+xml", + byteSize: 1320, + sha256: "storybook-diff-preview", + originalFilename: "document-diff-preview.png", + createdByAgentId: "agent-qa", + createdByUserId: null, + createdAt: new Date("2026-04-20T10:40:00.000Z"), + updatedAt: new Date("2026-04-20T10:40:00.000Z"), + contentPath: + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1400' height='900' viewBox='0 0 1400 900'%3E%3Crect width='1400' height='900' fill='%23171717'/%3E%3Crect x='110' y='104' width='1180' height='692' rx='24' fill='%230a0a0a' stroke='%23333333' stroke-width='4'/%3E%3Crect x='160' y='164' width='1080' height='48' rx='12' fill='%23262626'/%3E%3Crect x='160' y='260' width='1080' height='52' fill='%2315221a'/%3E%3Crect x='160' y='312' width='1080' height='52' fill='%23231818'/%3E%3Crect x='160' y='364' width='1080' height='52' fill='%2315221a'/%3E%3Crect x='160' y='468' width='1080' height='52' fill='%23231818'/%3E%3Crect x='160' y='520' width='1080' height='52' fill='%2315221a'/%3E%3Crect x='220' y='276' width='720' height='18' rx='9' fill='%2374c69d'/%3E%3Crect x='220' y='328' width='540' height='18' rx='9' fill='%23fca5a5'/%3E%3Crect x='220' y='380' width='820' height='18' rx='9' fill='%2374c69d'/%3E%3Crect x='220' y='484' width='480' height='18' rx='9' fill='%23fca5a5'/%3E%3Crect x='220' y='536' width='760' height='18' rx='9' fill='%2374c69d'/%3E%3C/svg%3E", + }, +]; + +function Section({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string; + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+
{eyebrow}
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+
+
{children}
+
+ ); +} + +function StoryShell({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function DialogBackdropFrame({ + eyebrow, + title, + description, + badges, +}: { + eyebrow: string; + title: string; + description: string; + badges: string[]; +}) { + return ( + +
+
+
+
+
+
+
+
+
+
+
+
+
Story state
+
+ {badges.map((badge) => ( + + {badge} + + ))} +
+
+
+
+
+ ); +} + +function hydrateDialogQueries(queryClient: ReturnType) { + queryClient.setQueryData(queryKeys.companies.all, storybookCompanies); + queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession); + queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), storybookAgents); + queryClient.setQueryData(queryKeys.projects.list(COMPANY_ID), storybookProjects); + queryClient.setQueryData(queryKeys.goals.list(COMPANY_ID), storybookGoals); + queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues); + queryClient.setQueryData(queryKeys.issues.labels(COMPANY_ID), storybookIssueLabels); + queryClient.setQueryData(queryKeys.issues.documents("issue-storybook-1"), storybookIssueDocuments); + queryClient.setQueryData(queryKeys.issues.documentRevisions("issue-storybook-1", "plan"), documentRevisions); + queryClient.setQueryData(queryKeys.executionWorkspaces.closeReadiness("execution-workspace-storybook"), closeReadinessReady); + queryClient.setQueryData(queryKeys.executionWorkspaces.closeReadiness("execution-workspace-blocked"), closeReadinessBlocked); + queryClient.setQueryData( + queryKeys.executionWorkspaces.list(COMPANY_ID, { + projectId: "project-board-ui", + projectWorkspaceId: "workspace-board-ui", + reuseEligible: true, + }), + storybookExecutionWorkspaces, + ); + queryClient.setQueryData(queryKeys.instance.experimentalSettings, { + enableIsolatedWorkspaces: true, + enableRoutineTriggers: true, + }); + queryClient.setQueryData(queryKeys.access.companyUserDirectory(COMPANY_ID), { + users: [ + { + principalId: "user-board", + status: "active", + user: { + id: "user-board", + email: "riley@paperclip.local", + name: "Riley Board", + image: null, + }, + }, + ], + }); + queryClient.setQueryData( + queryKeys.sidebarPreferences.projectOrder(COMPANY_ID, storybookAuthSession.user.id), + { orderedIds: storybookProjects.map((project) => project.id), updatedAt: null }, + ); + queryClient.setQueryData(queryKeys.adapters.all, [ + { + type: "codex_local", + label: "Codex local", + source: "builtin", + modelsCount: 5, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }, + }, + { + type: "claude_local", + label: "Claude local", + source: "builtin", + modelsCount: 4, + loaded: true, + disabled: false, + capabilities: { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }, + }, + ]); + queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "codex_local"), [ + { id: "gpt-5.4", label: "GPT-5.4" }, + { id: "gpt-5.4-mini", label: "GPT-5.4 Mini" }, + ]); +} + +function StorybookDialogFixtures({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + const [ready] = useState(() => { + if (typeof window !== "undefined") { + window.localStorage.setItem(SELECTED_COMPANY_STORAGE_KEY, COMPANY_ID); + window.localStorage.removeItem(ISSUE_DRAFT_STORAGE_KEY); + } + hydrateDialogQueries(queryClient); + return true; + }); + + return ready ? children : null; +} + +function useIssueCreateErrorMock(enabled: boolean) { + useLayoutEffect(() => { + if (!enabled || typeof window === "undefined") return undefined; + + const originalFetch = window.fetch.bind(window); + window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const rawUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const url = new URL(rawUrl, window.location.origin); + if (url.pathname === `/api/companies/${COMPANY_ID}/issues` && init?.method === "POST") { + return Response.json( + { error: "Validation failed: add a reviewer before creating governed release work." }, + { status: 422 }, + ); + } + return originalFetch(input, init); + }; + + return () => { + window.fetch = originalFetch; + }; + }, [enabled]); +} + +function setFieldValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) { + const prototype = Object.getPrototypeOf(element) as HTMLInputElement | HTMLTextAreaElement; + const valueSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + valueSetter?.call(element, value); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); +} + +function fillFirstField(selector: string, value: string) { + const element = document.querySelector(selector); + if (!element) return false; + setFieldValue(element, value); + return true; +} + +function clickButtonByText(text: string) { + const buttons = Array.from(document.querySelectorAll("button")); + const button = buttons.find((candidate) => candidate.textContent?.trim().includes(text)); + button?.click(); +} + +function useOpenWhenCompanyReady(open: () => void) { + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + const didOpenRef = useRef(false); + + useLayoutEffect(() => { + if (selectedCompanyId !== COMPANY_ID) { + setSelectedCompanyId(COMPANY_ID); + return; + } + if (didOpenRef.current) return; + didOpenRef.current = true; + open(); + }, [open, selectedCompanyId, setSelectedCompanyId]); +} + +function IssueDialogOpener({ + variant, +}: { + variant: "empty" | "prefilled" | "validation"; +}) { + const { openNewIssue } = useDialog(); + useIssueCreateErrorMock(variant === "validation"); + + useOpenWhenCompanyReady(() => { + openNewIssue( + variant === "empty" + ? {} + : { + title: variant === "validation" ? "Ship guarded release checklist" : "Create dialog Storybook coverage", + description: [ + "Cover modal flows with fixture-backed states.", + "", + "- Keep dialogs open by default", + "- Show project workspace selection", + "- Include reviewer and approver context", + ].join("\n"), + status: "todo", + priority: "high", + projectId: "project-board-ui", + projectWorkspaceId: "workspace-board-ui", + assigneeAgentId: "agent-codex", + executionWorkspaceMode: "isolated_workspace", + }, + ); + }); + + useEffect(() => { + if (variant !== "validation") return undefined; + const timer = window.setTimeout(() => { + clickButtonByText("Create Issue"); + }, 500); + return () => window.clearTimeout(timer); + }, [variant]); + + return ; +} + +function AgentDialogOpener({ advanced }: { advanced?: boolean }) { + const { openNewAgent } = useDialog(); + + useOpenWhenCompanyReady(() => { + openNewAgent(); + }); + + useEffect(() => { + if (!advanced) return undefined; + const timer = window.setTimeout(() => { + clickButtonByText("advanced configuration"); + }, 250); + return () => window.clearTimeout(timer); + }, [advanced]); + + return ; +} + +function GoalDialogOpener({ populated }: { populated?: boolean }) { + const { openNewGoal } = useDialog(); + + useOpenWhenCompanyReady(() => { + openNewGoal(populated ? { parentId: "goal-company" } : {}); + }); + + useEffect(() => { + if (!populated) return undefined; + const timer = window.setTimeout(() => { + fillFirstField("input[placeholder='Goal title']", "Add modal review coverage"); + }, 250); + return () => window.clearTimeout(timer); + }, [populated]); + + return ; +} + +function ProjectDialogOpener({ populated }: { populated?: boolean }) { + const { openNewProject } = useDialog(); + + useOpenWhenCompanyReady(() => { + openNewProject(); + }); + + useEffect(() => { + if (!populated) return undefined; + const timer = window.setTimeout(() => { + fillFirstField("input[placeholder='Project name']", "Storybook review workspace"); + fillFirstField("input[placeholder='https://github.com/org/repo']", "https://github.com/paperclipai/paperclip"); + fillFirstField("input[placeholder='/absolute/path/to/workspace']", "/Users/dotta/paperclip/ui"); + fillFirstField("input[type='date']", "2026-04-30"); + }, 250); + return () => window.clearTimeout(timer); + }, [populated]); + + return ; +} + +function DialogStory({ + eyebrow, + title, + description, + badges, + children, +}: { + eyebrow: string; + title: string; + description: string; + badges: string[]; + children: ReactNode; +}) { + return ( + + + {children} + + ); +} + +function ExecutionWorkspaceDialogStory({ blocked }: { blocked?: boolean }) { + const workspace = storybookExecutionWorkspaces[0]!; + return ( + + undefined} + /> + + ); +} + +function DocumentDiffModalStory() { + return ( + + undefined} + /> + + ); +} + +function ImageGalleryModalStory() { + return ( + + undefined} /> + + ); +} + +function PathInstructionsModalStory() { + return ( + + undefined} /> + + ); +} + +const meta = { + title: "Product/Dialogs & Modals", + parameters: { + docs: { + description: { + component: + "Open-state stories for Paperclip creation dialogs, workspace confirmations, document diffing, image attachments, and path helper modals.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const NewIssueEmpty: Story = { + name: "New Issue - Empty", + render: () => ( + + + + ), +}; + +export const NewIssuePrefilled: Story = { + name: "New Issue - Prefilled", + render: () => ( + + + + ), +}; + +export const NewIssueValidationError: Story = { + name: "New Issue - Validation Error", + render: () => ( + + + + ), +}; + +export const NewAgentRecommendation: Story = { + name: "New Agent - Recommendation", + render: () => ( + + + + ), +}; + +export const NewAgentAdapterSelection: Story = { + name: "New Agent - Adapter Selection", + render: () => ( + + + + ), +}; + +export const NewGoalEmpty: Story = { + name: "New Goal - Empty", + render: () => ( + + + + ), +}; + +export const NewGoalWithParent: Story = { + name: "New Goal - Parent Selected", + render: () => ( + + + + ), +}; + +export const NewProjectEmpty: Story = { + name: "New Project - Empty", + render: () => ( + + + + ), +}; + +export const NewProjectWorkspaceConfig: Story = { + name: "New Project - Workspace Config", + render: () => ( + + + + ), +}; + +export const ExecutionWorkspaceCloseReady: Story = { + name: "Execution Workspace Close - Ready", + render: () => , +}; + +export const ExecutionWorkspaceCloseBlocked: Story = { + name: "Execution Workspace Close - Blocked", + render: () => , +}; + +export const DocumentDiffOpen: Story = { + name: "Document Diff", + render: () => , +}; + +export const ImageGalleryOpen: Story = { + name: "Image Gallery", + render: () => , +}; + +export const PathInstructionsOpen: Story = { + name: "Path Instructions", + render: () => , +}; diff --git a/ui/storybook/stories/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx new file mode 100644 index 0000000000..b73a276901 --- /dev/null +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -0,0 +1,712 @@ +import { useMemo, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { Agent, CompanySecret, EnvBinding, Project, RoutineVariable } from "@paperclipai/shared"; +import { Code2, FileText, ListPlus, RotateCcw, Table2 } from "lucide-react"; +import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { ExecutionParticipantPicker } from "@/components/ExecutionParticipantPicker"; +import { InlineEditor } from "@/components/InlineEditor"; +import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector"; +import { JsonSchemaForm, type JsonSchemaNode, getDefaultValues } from "@/components/JsonSchemaForm"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { MarkdownEditor, type MentionOption } from "@/components/MarkdownEditor"; +import { ReportsToPicker } from "@/components/ReportsToPicker"; +import { + RoutineRunVariablesDialog, + type RoutineRunDialogSubmitData, +} from "@/components/RoutineRunVariablesDialog"; +import { RoutineVariablesEditor, RoutineVariablesHint } from "@/components/RoutineVariablesEditor"; +import { ScheduleEditor, describeSchedule } from "@/components/ScheduleEditor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { buildExecutionPolicy } from "@/lib/issue-execution-policy"; +import { createIssue, storybookAgents } from "../fixtures/paperclipData"; + +function Section({ + eyebrow, + title, + description, + children, +}: { + eyebrow: string; + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+
{eyebrow}
+
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+
+
+
{children}
+
+ ); +} + +function StatePanel({ + label, + detail, + children, + disabled = false, +}: { + label: string; + detail?: string; + children: ReactNode; + disabled?: boolean; +}) { + return ( +
+
+
+
{label}
+ {detail ?
{detail}
: null} +
+ {disabled ? disabled : null} +
+
{children}
+
+ ); +} + +function StoryShell({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +const reviewMarkdown = `# Release review + +Ship criteria for the board UI refresh: + +- [x] Preserve company-scoped routes +- [x] Keep comments and task updates auditable +- [ ] Attach screenshots after QA + +| Surface | Owner | State | +| --- | --- | --- | +| Issues | CodexCoder | In progress | +| Approvals | CTO | Ready | + +\`\`\`ts +const shouldRun = issue.status === "in_progress" && issue.companyId === company.id; +\`\`\` + +See [the implementation notes](https://github.com/paperclipai/paperclip).`; + +const editorMentions: MentionOption[] = [ + { id: "agent-codex", name: "CodexCoder", kind: "agent", agentId: "agent-codex", agentIcon: "code" }, + { id: "agent-qa", name: "QAChecker", kind: "agent", agentId: "agent-qa", agentIcon: "shield" }, + { id: "project-board-ui", name: "Board UI", kind: "project", projectId: "project-board-ui", projectColor: "#0f766e" }, + { id: "user-board", name: "Board Operator", kind: "user", userId: "user-board" }, +]; + +const adapterSchema: JsonSchemaNode = { + type: "object", + required: ["adapterName", "apiKey", "concurrency"], + properties: { + adapterName: { + type: "string", + title: "Adapter name", + description: "Human-readable name shown in the adapter manager.", + minLength: 3, + default: "Codex local", + }, + mode: { + type: "string", + title: "Run mode", + enum: ["review", "implementation", "maintenance"], + default: "implementation", + }, + apiKey: { + type: "string", + title: "API key", + format: "secret-ref", + description: "Stored with the active Paperclip secret provider.", + }, + concurrency: { + type: "integer", + title: "Max concurrent runs", + minimum: 1, + maximum: 6, + default: 2, + }, + dryRun: { + type: "boolean", + title: "Dry run first", + description: "Require a preview run before mutating company data.", + default: true, + }, + notes: { + type: "string", + title: "Operator notes", + format: "textarea", + maxLength: 500, + description: "Shown to the agent before checkout.", + }, + allowedCommands: { + type: "array", + title: "Allowed commands", + description: "Commands this adapter can run without extra approval.", + items: { type: "string", default: "pnpm test" }, + minItems: 1, + }, + advanced: { + type: "object", + title: "Advanced guardrails", + properties: { + timeoutSeconds: { type: "integer", title: "Timeout seconds", minimum: 60, default: 900 }, + requireApproval: { type: "boolean", title: "Require board approval", default: false }, + }, + }, + }, +}; + +const validAdapterValues = { + ...getDefaultValues(adapterSchema), + adapterName: "Codex local", + mode: "implementation", + apiKey: "secret:openai-api-key", + concurrency: 2, + dryRun: true, + notes: "Use the project worktree and post a concise task update before handoff.", + allowedCommands: ["pnpm --filter @paperclipai/ui typecheck", "pnpm build-storybook"], + advanced: { timeoutSeconds: 900, requireApproval: false }, +}; + +const invalidAdapterValues = { + ...validAdapterValues, + adapterName: "AI", + apiKey: "", + concurrency: 9, +}; + +const adapterErrors = { + "/adapterName": "Must be at least 3 characters", + "/apiKey": "This field is required", + "/concurrency": "Must be at most 6", +}; + +const storybookSecrets: CompanySecret[] = [ + { + id: "secret-openai", + companyId: "company-storybook", + name: "OPENAI_API_KEY", + provider: "local_encrypted", + externalRef: null, + latestVersion: 3, + description: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdAt: new Date("2026-04-18T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), + }, + { + id: "secret-github", + companyId: "company-storybook", + name: "GITHUB_TOKEN", + provider: "local_encrypted", + externalRef: null, + latestVersion: 1, + description: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdAt: new Date("2026-04-19T10:00:00.000Z"), + updatedAt: new Date("2026-04-19T10:00:00.000Z"), + }, +]; + +const filledEnv: Record = { + NODE_ENV: { type: "plain", value: "development" }, + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, +}; + +const routineVariables: RoutineVariable[] = [ + { + name: "repo", + label: "Repository", + type: "text", + defaultValue: "paperclipai/paperclip", + required: true, + options: [], + }, + { + name: "priority", + label: "Priority", + type: "select", + defaultValue: "medium", + required: true, + options: ["low", "medium", "high"], + }, + { + name: "include_browser", + label: "Include browser QA", + type: "boolean", + defaultValue: true, + required: false, + options: [], + }, + { + name: "notes", + label: "Run notes", + type: "textarea", + defaultValue: "Capture any visible layout regressions.", + required: false, + options: [], + }, +]; + +const storybookProject: Project = { + id: "project-board-ui", + companyId: "company-storybook", + urlKey: "board-ui", + goalId: "goal-company", + goalIds: ["goal-company"], + goals: [{ id: "goal-company", title: "We're building Paperclip" }], + name: "Board UI", + description: "Control-plane interface, Storybook review surfaces, and operator workflows.", + status: "in_progress", + leadAgentId: "agent-codex", + targetDate: null, + color: "#0f766e", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: "workspace-board-ui", + repoUrl: "https://github.com/paperclipai/paperclip", + repoRef: "master", + defaultRef: "master", + repoName: "paperclip", + localFolder: "/Users/dotta/paperclip", + managedFolder: "paperclip", + effectiveLocalFolder: "/Users/dotta/paperclip", + origin: "local_folder", + }, + workspaces: [], + primaryWorkspace: null, + archivedAt: null, + createdAt: new Date("2026-04-01T10:00:00.000Z"), + updatedAt: new Date("2026-04-20T10:00:00.000Z"), +}; + +const entityOptions: InlineEntityOption[] = [ + { id: "issue-1672", label: "Storybook forms and editors", searchText: "PAP-1672 ui story coverage" }, + { id: "project-board-ui", label: "Board UI", searchText: "project frontend Storybook" }, + { id: "agent-codex", label: "CodexCoder", searchText: "engineer implementation" }, +]; + +function MarkdownEditorGallery() { + const [emptyMarkdown, setEmptyMarkdown] = useState(""); + const [filledMarkdown, setFilledMarkdown] = useState(reviewMarkdown); + const [actionMarkdown, setActionMarkdown] = useState("Draft an update for @CodexCoder and /check-pr."); + + return ( +
+
+ + + + + + + + undefined} readOnly mentions={editorMentions} /> + + +
+ + + + + +
+ +
+
+
+ ); +} + +function MarkdownBodyGallery() { + return ( +
+
+ + {reviewMarkdown} + +
+ + {""} +

No markdown body content.

+
+ + A read-only preview can be dimmed by the parent surface. + +
+
+
+ ); +} + +function JsonSchemaFormGallery() { + const [filledValues, setFilledValues] = useState>(validAdapterValues); + const [errorValues, setErrorValues] = useState>(invalidAdapterValues); + + return ( +
+
+ + + + + + + + undefined} /> + + + undefined} disabled /> + +
+
+ ); +} + +function InlineEditorGallery() { + const [title, setTitle] = useState("Storybook: Forms & Editors stories"); + const [description, setDescription] = useState( + "Create fixture-backed editor stories for the board UI, then verify Storybook builds.", + ); + const [emptyTitle, setEmptyTitle] = useState(""); + + return ( +
+
+ + + + + + + + + +
+
+ ); +} + +function EnvVarEditorGallery() { + const [emptyEnv, setEmptyEnv] = useState>({}); + const [env, setEnv] = useState>(filledEnv); + const createSecret = async (name: string): Promise => ({ + ...storybookSecrets[0]!, + id: `secret-${name.toLowerCase()}`, + name, + latestVersion: 1, + }); + + return ( +
+
+ + setEmptyEnv(next ?? {})} /> + + + setEnv(next ?? {})} /> + + + undefined} /> + +
+
+ ); +} + +function ScheduleEditorGallery() { + const [emptyCron, setEmptyCron] = useState(""); + const [weeklyCron, setWeeklyCron] = useState("30 9 * * 1"); + const [customCron, setCustomCron] = useState("15 16 1 * *"); + + return ( +
+
+ + + + + + + + + +
+
+ ); +} + +function RoutineVariablesGallery() { + const [variables, setVariables] = useState(routineVariables); + + return ( +
+
+ + + +
+ + + + + undefined} + /> + +
+
+
+ ); +} + +function PickerGallery() { + const [issue, setIssue] = useState(() => + createIssue({ + executionPolicy: buildExecutionPolicy({ + reviewerValues: ["agent:agent-qa"], + approverValues: ["user:user-board"], + }), + }), + ); + const [manager, setManager] = useState("agent-cto"); + const [selectorValue, setSelectorValue] = useState("project-board-ui"); + const agentsWithTerminated: Agent[] = useMemo( + () => [ + ...storybookAgents, + { + ...storybookAgents[1]!, + id: "agent-legacy", + name: "LegacyReviewer", + status: "terminated", + reportsTo: null, + }, + ], + [], + ); + + return ( +
+
+ +
+ setIssue((current) => ({ ...current, ...patch }))} + /> + setIssue((current) => ({ ...current, ...patch }))} + /> +
+
+ +
+ + undefined} disabled /> +
+
+ +
+ +
+ undefined} + /> +
+
+
+
+
+ ); +} + +function FormsEditorsShowcase() { + return ( + +
+
+
+
Forms and editors
+

Paperclip form controls under realistic state

+

+ Dense control-plane forms need to hold empty, filled, validation, and disabled states without losing scan + speed. These fixtures keep the components reviewable outside production routes. +

+
+
+ empty + filled + validation + disabled +
+
+
+ + + + + + + + + +
+ ); +} + +function RoutineRunDialogStory() { + const [open, setOpen] = useState(true); + const [submitted, setSubmitted] = useState(null); + + return ( + +
+
+ + {submitted ? ( +
+              {JSON.stringify(submitted, null, 2)}
+            
+ ) : ( + Submit the dialog to inspect the payload. + )} +
+
+ { + setSubmitted({ ...data }); + setOpen(false); + }} + /> +
+ ); +} + +const meta = { + title: "Components/Forms & Editors", + parameters: { + docs: { + description: { + component: + "Fixture-backed stories for Paperclip form controls, markdown editors, inline editors, schedule controls, runtime-variable dialogs, and selection pickers.", + }, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AllFormsAndEditors: Story = { + name: "All Forms And Editors", + render: () => , +}; + +export const RoutineRunVariablesDialogOpen: Story = { + name: "Routine Run Variables Dialog", + render: () => , +}; diff --git a/ui/storybook/stories/foundations.stories.tsx b/ui/storybook/stories/foundations.stories.tsx new file mode 100644 index 0000000000..94c1a6874e --- /dev/null +++ b/ui/storybook/stories/foundations.stories.tsx @@ -0,0 +1,300 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AlertTriangle, ArrowRight, Check, Copy, Play, Plus, Save, Search, Settings } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +const buttonVariants = ["default", "secondary", "outline", "ghost", "destructive", "link"] as const; +const buttonSizes = ["xs", "sm", "default", "lg", "icon", "icon-sm"] as const; +const badgeVariants = ["default", "secondary", "outline", "destructive", "ghost", "link"] as const; + +function Section({ + eyebrow, + title, + children, +}: { + eyebrow: string; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+
{eyebrow}
+

{title}

+
+
{children}
+
+ ); +} + +function FoundationsMatrix() { + const [autoMode, setAutoMode] = useState(true); + const [boardApproval, setBoardApproval] = useState(true); + + return ( +
+
+
+
Foundations
+

Primitives and interaction states

+

+ A dense pass over the base controls that Paperclip pages use for operational actions, filtering, + approvals, and settings. +

+
+ +
+
+
+ {buttonVariants.map((variant) => ( +
+
{variant}
+
+ + +
+
+ ))} +
+
+
Sizes and icon-only actions
+
+ {buttonSizes.map((size) => ( + + ))} +
+
+
+
+ +
+
+ {badgeVariants.map((variant) => ( + + {variant === "destructive" ? : variant === "default" ? : null} + {variant} + + ))} + + PAP-1641 + + + + in review + +
+
+ +
+
+
+
+ + +
+
+ +