diff --git a/ui/src/components/BudgetSidebarMarker.tsx b/ui/src/components/BudgetSidebarMarker.tsx index 43f10b95261..07e5ddfc07d 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 aefe1020f0a..2d009dba38c 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 00000000000..f47ff68aa14 --- /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/forms-editors.stories.tsx b/ui/storybook/stories/forms-editors.stories.tsx new file mode 100644 index 00000000000..26cbdd74c86 --- /dev/null +++ b/ui/storybook/stories/forms-editors.stories.tsx @@ -0,0 +1,709 @@ +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 } 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>(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: () => , +};