mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
feat(ui): add agent-management, budget-finance, and forms-editors stories
Add three new comprehensive story files covering agent management components, budget/finance UI, and form/editor components. Includes supporting component changes: BudgetSidebarMarker level prop, CompanySwitcher controlled open prop, MarkdownEditor readOnly prop. Updates preview.tsx and fixture data to support the new stories. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5b8894a912
commit
e078ee7615
12 files changed
+3047
-85
No files matched your search
@@ -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<BudgetSidebarMarkerLevel, string> = {
|
||||
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<BudgetSidebarMarkerLevel, string> = {
|
||||
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 (
|
||||
<span
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-red-500/90 text-white shadow-[0_0_0_1px_rgba(255,255,255,0.08)]"
|
||||
title={accessibleTitle}
|
||||
aria-label={accessibleTitle}
|
||||
className={`ml-auto inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full shadow-[0_0_0_1px_rgba(255,255,255,0.08)] ${levelClasses[level]}`}
|
||||
>
|
||||
<DollarSign className="h-3 w-3" />
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -51,6 +51,45 @@ function KeyCap({ children }: { children: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsCheatsheetContent() {
|
||||
return (
|
||||
<>
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="px-5 py-3">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.label + shortcut.keys.join()}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span className="text-sm text-foreground/90">{shortcut.label}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||
<KeyCap>{key}</KeyCap>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <KeyCap>Esc</KeyCap> to close · Shortcuts are disabled in text fields
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsCheatsheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -64,38 +103,7 @@ export function KeyboardShortcutsCheatsheet({
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="text-base">Keyboard shortcuts</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="px-5 py-3">
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{section.shortcuts.map((shortcut) => (
|
||||
<div
|
||||
key={shortcut.label + shortcut.keys.join()}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<span className="text-sm text-foreground/90">{shortcut.label}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||
<KeyCap>{key}</KeyCap>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press <KeyCap>Esc</KeyCap> to close · Shortcuts are disabled in text fields
|
||||
</p>
|
||||
</div>
|
||||
<KeyboardShortcutsCheatsheetContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,8 @@ interface MarkdownEditorProps {
|
||||
mentions?: MentionOption[];
|
||||
/** Called on Cmd/Ctrl+Enter */
|
||||
onSubmit?: () => void;
|
||||
/** Render the rich editor without allowing edits. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface MarkdownEditorRef {
|
||||
@@ -492,6 +494,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
bordered = true,
|
||||
mentions,
|
||||
onSubmit,
|
||||
readOnly = false,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const editorValue = useMemo(() => prepareMarkdownForEditor(value), [value]);
|
||||
const { slashCommands } = useEditorAutocomplete();
|
||||
@@ -944,7 +947,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
ref={fallbackTextareaRef}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(event) => {
|
||||
if (readOnly) return;
|
||||
onChange(event.target.value);
|
||||
autoSizeFallbackTextarea(event.target);
|
||||
}}
|
||||
@@ -974,6 +979,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
className,
|
||||
)}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (readOnly) return;
|
||||
// Cmd/Ctrl+Enter to submit
|
||||
if (onSubmit && e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
@@ -1031,21 +1037,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
}
|
||||
}}
|
||||
onDragEnter={(evt) => {
|
||||
if (readOnly) return;
|
||||
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragOver={(evt) => {
|
||||
if (readOnly) return;
|
||||
if (!canDropFile || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (readOnly) return;
|
||||
if (!canDropFile) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}}
|
||||
onDrop={(evt) => {
|
||||
if (readOnly) return;
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
if (!onDropFile) return;
|
||||
@@ -1073,7 +1083,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
ref={setEditorRef}
|
||||
markdown={editorValue}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
if (readOnly) return;
|
||||
const echo = echoIgnoreMarkdownRef.current;
|
||||
if (echo !== null && next === echo) {
|
||||
echoIgnoreMarkdownRef.current = null;
|
||||
|
||||
@@ -26,6 +26,8 @@ const DOCS_URL = "https://docs.paperclip.ing/";
|
||||
interface SidebarAccountMenuProps {
|
||||
deploymentMode?: DeploymentMode;
|
||||
instanceSettingsTarget: string;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
version?: string | null;
|
||||
}
|
||||
|
||||
@@ -102,12 +104,16 @@ function MenuAction({ label, description, icon: Icon, onClick, href, external =
|
||||
export function SidebarAccountMenu({
|
||||
deploymentMode,
|
||||
instanceSettingsTarget,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
version,
|
||||
}: SidebarAccountMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
||||
@@ -16,11 +16,18 @@ import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
||||
export function SidebarCompanyMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
interface SidebarCompanyMenuProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompany } = useCompany();
|
||||
const { isMobile, setSidebarOpen } = useSidebar();
|
||||
const open = controlledOpen ?? internalOpen;
|
||||
const setOpen = onOpenChange ?? setInternalOpen;
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
storybook-static
|
||||
@@ -11,33 +11,21 @@ import { SidebarProvider } from "@/context/SidebarContext";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { ToastProvider } from "@/context/ToastContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookApprovals,
|
||||
storybookAuthSession,
|
||||
storybookCompanies,
|
||||
storybookDashboardSummary,
|
||||
storybookIssues,
|
||||
storybookLiveRuns,
|
||||
storybookProjects,
|
||||
storybookSidebarBadges,
|
||||
} from "../fixtures/paperclipData";
|
||||
import "@mdxeditor/editor/style.css";
|
||||
import "@/index.css";
|
||||
import "./styles.css";
|
||||
|
||||
const storybookCompany = {
|
||||
id: "company-storybook",
|
||||
name: "Paperclip Storybook",
|
||||
description: "Fixture company for isolated UI review.",
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1641,
|
||||
budgetMonthlyCents: 250_000,
|
||||
spentMonthlyCents: 67_500,
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
feedbackDataSharingEnabled: true,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#0f766e",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-01T09:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T12:00:00.000Z"),
|
||||
};
|
||||
|
||||
function installStorybookApiFixtures() {
|
||||
if (typeof window === "undefined") return;
|
||||
const currentWindow = window as typeof window & {
|
||||
@@ -57,8 +45,100 @@ function installStorybookApiFixtures() {
|
||||
: input.url;
|
||||
const url = new URL(rawUrl, window.location.origin);
|
||||
|
||||
if (url.pathname === "/api/auth/get-session") {
|
||||
return Response.json(storybookAuthSession);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/companies") {
|
||||
return Response.json([storybookCompany]);
|
||||
return Response.json(storybookCompanies);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/companies/company-storybook/user-directory") {
|
||||
return Response.json({
|
||||
users: [
|
||||
{
|
||||
principalId: "user-board",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-board",
|
||||
email: "board@paperclip.local",
|
||||
name: "Board Operator",
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
principalId: "user-product",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-product",
|
||||
email: "product@paperclip.local",
|
||||
name: "Product Lead",
|
||||
image: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/instance/settings/experimental") {
|
||||
return Response.json({
|
||||
enableIsolatedWorkspaces: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/plugins/ui-contributions") {
|
||||
return Response.json([]);
|
||||
}
|
||||
|
||||
const companyResourceMatch = url.pathname.match(/^\/api\/companies\/([^/]+)\/([^/]+)$/);
|
||||
if (companyResourceMatch) {
|
||||
const [, companyId, resource] = companyResourceMatch;
|
||||
if (resource === "agents") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookAgents : []);
|
||||
}
|
||||
if (resource === "projects") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookProjects : []);
|
||||
}
|
||||
if (resource === "approvals") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookApprovals : []);
|
||||
}
|
||||
if (resource === "dashboard") {
|
||||
return Response.json({
|
||||
...storybookDashboardSummary,
|
||||
companyId,
|
||||
});
|
||||
}
|
||||
if (resource === "heartbeat-runs") {
|
||||
return Response.json([]);
|
||||
}
|
||||
if (resource === "live-runs") {
|
||||
return Response.json(companyId === "company-storybook" ? storybookLiveRuns : []);
|
||||
}
|
||||
if (resource === "inbox-dismissals") {
|
||||
return Response.json([]);
|
||||
}
|
||||
if (resource === "sidebar-badges") {
|
||||
return Response.json(
|
||||
companyId === "company-storybook"
|
||||
? storybookSidebarBadges
|
||||
: { inbox: 0, approvals: 0, failedRuns: 0, joinRequests: 0 },
|
||||
);
|
||||
}
|
||||
if (resource === "join-requests") {
|
||||
return Response.json([]);
|
||||
}
|
||||
if (resource === "issues") {
|
||||
const query = url.searchParams.get("q")?.trim().toLowerCase();
|
||||
const issues = companyId === "company-storybook" ? storybookIssues : [];
|
||||
return Response.json(
|
||||
query
|
||||
? issues.filter((issue) =>
|
||||
`${issue.identifier ?? ""} ${issue.title} ${issue.description ?? ""}`.toLowerCase().includes(query),
|
||||
)
|
||||
: issues,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/invites/") && url.pathname.endsWith("/logo")) {
|
||||
|
||||
@@ -2,13 +2,105 @@ import type {
|
||||
ActivityEvent,
|
||||
Agent,
|
||||
Approval,
|
||||
AuthSession,
|
||||
BudgetPolicySummary,
|
||||
Company,
|
||||
DashboardSummary,
|
||||
ExecutionWorkspace,
|
||||
Issue,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
Project,
|
||||
SidebarBadges,
|
||||
} from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "@/api/activity";
|
||||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
|
||||
const now = new Date("2026-04-20T12:00:00.000Z");
|
||||
const recent = (minutesAgo: number) => new Date(now.getTime() - minutesAgo * 60_000);
|
||||
|
||||
export const storybookCompanies: Company[] = [
|
||||
{
|
||||
id: "company-storybook",
|
||||
name: "Paperclip Storybook",
|
||||
description: "Fixture company for isolated UI review.",
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1641,
|
||||
budgetMonthlyCents: 250_000,
|
||||
spentMonthlyCents: 67_500,
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
feedbackDataSharingEnabled: true,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#0f766e",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-01T09:00:00.000Z"),
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "company-research",
|
||||
name: "Research Bureau",
|
||||
description: "A second active company for rail and switcher state coverage.",
|
||||
status: "active",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
issuePrefix: "RES",
|
||||
issueCounter: 88,
|
||||
budgetMonthlyCents: 180_000,
|
||||
spentMonthlyCents: 39_500,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#4f46e5",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-03T09:00:00.000Z"),
|
||||
updatedAt: recent(10),
|
||||
},
|
||||
{
|
||||
id: "company-paused",
|
||||
name: "Launch Ops",
|
||||
description: "Paused company for inactive switcher treatment.",
|
||||
status: "paused",
|
||||
pauseReason: "manual",
|
||||
pausedAt: recent(240),
|
||||
issuePrefix: "OPS",
|
||||
issueCounter: 204,
|
||||
budgetMonthlyCents: 90_000,
|
||||
spentMonthlyCents: 91_200,
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#c2410c",
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date("2026-04-05T09:00:00.000Z"),
|
||||
updatedAt: recent(240),
|
||||
},
|
||||
];
|
||||
|
||||
export const storybookAuthSession: AuthSession = {
|
||||
session: {
|
||||
id: "session-storybook",
|
||||
userId: "user-board",
|
||||
},
|
||||
user: {
|
||||
id: "user-board",
|
||||
name: "Riley Board",
|
||||
email: "riley@paperclip.local",
|
||||
image: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const storybookAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-codex",
|
||||
@@ -86,6 +178,218 @@ export const storybookAgents: Agent[] = [
|
||||
|
||||
export const storybookAgentMap = new Map(storybookAgents.map((agent) => [agent.id, agent]));
|
||||
|
||||
export const storybookIssueLabels: IssueLabel[] = [
|
||||
{
|
||||
id: "label-ui",
|
||||
companyId: "company-storybook",
|
||||
name: "UI",
|
||||
color: "#0f766e",
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(20_000),
|
||||
},
|
||||
{
|
||||
id: "label-docs",
|
||||
companyId: "company-storybook",
|
||||
name: "Design system",
|
||||
color: "#f59e0b",
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(20_000),
|
||||
},
|
||||
{
|
||||
id: "label-backend",
|
||||
companyId: "company-storybook",
|
||||
name: "API",
|
||||
color: "#2563eb",
|
||||
createdAt: recent(18_000),
|
||||
updatedAt: recent(18_000),
|
||||
},
|
||||
{
|
||||
id: "label-risk",
|
||||
companyId: "company-storybook",
|
||||
name: "Risk",
|
||||
color: "#dc2626",
|
||||
createdAt: recent(16_000),
|
||||
updatedAt: recent(16_000),
|
||||
},
|
||||
];
|
||||
|
||||
const storybookIssueLabelMap = new Map(storybookIssueLabels.map((label) => [label.id, label]));
|
||||
|
||||
function labelsFor(ids: string[]) {
|
||||
return ids.map((id) => storybookIssueLabelMap.get(id)).filter((label): label is IssueLabel => Boolean(label));
|
||||
}
|
||||
|
||||
export const storybookProjectWorkspaces = [
|
||||
{
|
||||
id: "workspace-board-ui",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
name: "Board UI",
|
||||
sourceType: "local_path" as const,
|
||||
cwd: "/Users/dotta/paperclip/ui",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
repoRef: "master",
|
||||
defaultRef: "master",
|
||||
visibility: "default" as const,
|
||||
setupCommand: "pnpm install",
|
||||
cleanupCommand: null,
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: "board-ui",
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
isPrimary: true,
|
||||
runtimeServices: [
|
||||
{
|
||||
id: "service-storybook",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
projectWorkspaceId: "workspace-board-ui",
|
||||
executionWorkspaceId: "execution-workspace-storybook",
|
||||
issueId: "issue-storybook-1",
|
||||
scopeType: "execution_workspace" as const,
|
||||
scopeId: "execution-workspace-storybook",
|
||||
serviceName: "storybook",
|
||||
status: "running" as const,
|
||||
lifecycle: "ephemeral" as const,
|
||||
reuseKey: "storybook",
|
||||
command: "pnpm storybook",
|
||||
cwd: "/Users/dotta/paperclip/ui",
|
||||
port: 6006,
|
||||
url: "http://localhost:6006",
|
||||
provider: "local_process" as const,
|
||||
providerRef: null,
|
||||
ownerAgentId: "agent-codex",
|
||||
startedByRunId: "run-storybook",
|
||||
lastUsedAt: recent(4),
|
||||
startedAt: recent(16),
|
||||
stoppedAt: null,
|
||||
stopPolicy: null,
|
||||
healthStatus: "healthy" as const,
|
||||
configIndex: 0,
|
||||
createdAt: recent(16),
|
||||
updatedAt: recent(4),
|
||||
},
|
||||
],
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(4),
|
||||
},
|
||||
];
|
||||
|
||||
export const storybookExecutionWorkspaces: ExecutionWorkspace[] = [
|
||||
{
|
||||
id: "execution-workspace-storybook",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
projectWorkspaceId: "workspace-board-ui",
|
||||
sourceIssueId: "issue-storybook-1",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-1641 storybook worktree",
|
||||
status: "active",
|
||||
cwd: "/Users/dotta/paperclip/.paperclip/worktrees/PAP-1641-create-super-detailed-storybooks-for-our-project",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
baseRef: "master",
|
||||
branchName: "PAP-1641-create-super-detailed-storybooks-for-our-project",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: recent(3),
|
||||
openedAt: recent(90),
|
||||
closedAt: null,
|
||||
cleanupEligibleAt: null,
|
||||
cleanupReason: null,
|
||||
config: null,
|
||||
metadata: null,
|
||||
runtimeServices: storybookProjectWorkspaces[0]!.runtimeServices,
|
||||
createdAt: recent(90),
|
||||
updatedAt: recent(3),
|
||||
},
|
||||
];
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
const id = overrides.id ?? "project-board-ui";
|
||||
return {
|
||||
id,
|
||||
companyId: "company-storybook",
|
||||
urlKey: "board-ui",
|
||||
goalId: "goal-company",
|
||||
goalIds: ["goal-company"],
|
||||
goals: [{ id: "goal-company", title: "Build Paperclip" }],
|
||||
name: "Board UI",
|
||||
description: "Navigation, command, and operator layout polish.",
|
||||
status: "in_progress",
|
||||
leadAgentId: "agent-codex",
|
||||
targetDate: "2026-04-30",
|
||||
color: "#0f766e",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
defaultMode: "isolated_workspace",
|
||||
allowIssueOverride: true,
|
||||
defaultProjectWorkspaceId: "workspace-board-ui",
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
baseRef: "master",
|
||||
branchTemplate: "{issueIdentifier}-{slug}",
|
||||
worktreeParentDir: "/Users/dotta/paperclip/.paperclip/worktrees",
|
||||
provisionCommand: null,
|
||||
teardownCommand: null,
|
||||
},
|
||||
workspaceRuntime: null,
|
||||
branchPolicy: null,
|
||||
pullRequestPolicy: null,
|
||||
runtimePolicy: null,
|
||||
cleanupPolicy: null,
|
||||
},
|
||||
codebase: {
|
||||
workspaceId: "workspace-board-ui",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
repoRef: "master",
|
||||
defaultRef: "master",
|
||||
repoName: "paperclip",
|
||||
localFolder: "/Users/dotta/paperclip",
|
||||
managedFolder: ".paperclip/worktrees/storybook",
|
||||
effectiveLocalFolder: "/Users/dotta/paperclip",
|
||||
origin: "local_folder",
|
||||
},
|
||||
workspaces: storybookProjectWorkspaces,
|
||||
primaryWorkspace: storybookProjectWorkspaces[0]!,
|
||||
archivedAt: null,
|
||||
createdAt: recent(18_000),
|
||||
updatedAt: recent(12),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const storybookProjects: Project[] = [
|
||||
createProject(),
|
||||
createProject({
|
||||
id: "project-runtime",
|
||||
urlKey: "agent-runtime",
|
||||
name: "Agent Runtime",
|
||||
description: "Heartbeat, adapters, and execution trace work.",
|
||||
status: "planned",
|
||||
leadAgentId: "agent-cto",
|
||||
color: "#2563eb",
|
||||
updatedAt: recent(60),
|
||||
}),
|
||||
createProject({
|
||||
id: "project-budget",
|
||||
urlKey: "budget-guardrails",
|
||||
name: "Budget Guardrails",
|
||||
description: "Hard-stop and approval flow review surfaces.",
|
||||
status: "in_progress",
|
||||
leadAgentId: "agent-qa",
|
||||
color: "#f59e0b",
|
||||
pauseReason: "budget",
|
||||
pausedAt: recent(90),
|
||||
updatedAt: recent(90),
|
||||
}),
|
||||
];
|
||||
|
||||
export function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-storybook-1",
|
||||
@@ -119,32 +423,15 @@ export function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
labelIds: ["label-ui", "label-docs"],
|
||||
labels: [
|
||||
{
|
||||
id: "label-ui",
|
||||
companyId: "company-storybook",
|
||||
name: "UI",
|
||||
color: "#0f766e",
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(20_000),
|
||||
},
|
||||
{
|
||||
id: "label-docs",
|
||||
companyId: "company-storybook",
|
||||
name: "Design system",
|
||||
color: "#f59e0b",
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(20_000),
|
||||
},
|
||||
],
|
||||
labels: labelsFor(["label-ui", "label-docs"]),
|
||||
blockedBy: [],
|
||||
blocks: [],
|
||||
planDocument: null,
|
||||
documentSummaries: [],
|
||||
legacyPlanDocument: null,
|
||||
project: null,
|
||||
project: storybookProjects[0]!,
|
||||
goal: null,
|
||||
currentExecutionWorkspace: null,
|
||||
currentExecutionWorkspace: storybookExecutionWorkspaces[0]!,
|
||||
workProducts: [],
|
||||
mentionedProjects: [],
|
||||
myLastTouchAt: recent(8),
|
||||
@@ -162,6 +449,7 @@ export const storybookIssues: Issue[] = [
|
||||
createIssue({
|
||||
id: "issue-storybook-2",
|
||||
title: "Add budget hard-stop incident review",
|
||||
description: "Trace why a hard stop paused the agent and add a board-facing incident summary.",
|
||||
status: "blocked",
|
||||
priority: "critical",
|
||||
assigneeAgentId: "agent-qa",
|
||||
@@ -171,6 +459,22 @@ export const storybookIssues: Issue[] = [
|
||||
startedAt: null,
|
||||
identifier: "PAP-1528",
|
||||
issueNumber: 1528,
|
||||
billingCode: "reliability",
|
||||
projectId: "project-budget",
|
||||
projectWorkspaceId: null,
|
||||
labelIds: ["label-risk", "label-backend"],
|
||||
labels: labelsFor(["label-risk", "label-backend"]),
|
||||
blockedBy: [
|
||||
{
|
||||
id: "issue-storybook-7",
|
||||
identifier: "PAP-1591",
|
||||
title: "Confirm project budget override policy",
|
||||
status: "in_review",
|
||||
priority: "high",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "user-board",
|
||||
},
|
||||
],
|
||||
lastActivityAt: recent(18),
|
||||
}),
|
||||
createIssue({
|
||||
@@ -189,6 +493,215 @@ export const storybookIssues: Issue[] = [
|
||||
lastActivityAt: recent(49),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-storybook-4",
|
||||
parentId: "issue-storybook-1",
|
||||
title: "Extract issue row density fixtures",
|
||||
description: "Create fixture-backed rows for unread, selected, nested, and grouped issue management views.",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-codex",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
identifier: "PAP-1668",
|
||||
issueNumber: 1668,
|
||||
labelIds: ["label-ui"],
|
||||
labels: labelsFor(["label-ui"]),
|
||||
lastActivityAt: recent(31),
|
||||
isUnreadForMe: true,
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-storybook-5",
|
||||
parentId: "issue-storybook-1",
|
||||
title: "Review document editor empty states",
|
||||
description: "Validate plan and notes documents in issue detail before handing the Storybook preview to QA.",
|
||||
status: "done",
|
||||
priority: "low",
|
||||
assigneeAgentId: "agent-qa",
|
||||
checkoutRunId: null,
|
||||
executionRunId: "run-storybook-qa",
|
||||
executionLockedAt: null,
|
||||
completedAt: recent(22),
|
||||
identifier: "PAP-1669",
|
||||
issueNumber: 1669,
|
||||
labelIds: ["label-docs"],
|
||||
labels: labelsFor(["label-docs"]),
|
||||
lastActivityAt: recent(22),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-storybook-6",
|
||||
title: "Publish static Storybook preview",
|
||||
description: "Build the static preview and attach the generated artifact to the parent issue.",
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
identifier: "PAP-1670",
|
||||
issueNumber: 1670,
|
||||
labelIds: ["label-ui", "label-risk"],
|
||||
labels: labelsFor(["label-ui", "label-risk"]),
|
||||
lastActivityAt: recent(64),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-storybook-7",
|
||||
title: "Confirm project budget override policy",
|
||||
description: "Board review needed before increasing the project budget for long-running browser verification.",
|
||||
status: "in_review",
|
||||
priority: "high",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "user-board",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
identifier: "PAP-1591",
|
||||
issueNumber: 1591,
|
||||
billingCode: "governance",
|
||||
projectId: "project-budget",
|
||||
projectWorkspaceId: null,
|
||||
labelIds: ["label-risk"],
|
||||
labels: labelsFor(["label-risk"]),
|
||||
lastActivityAt: recent(85),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export const storybookIssueDocuments: IssueDocument[] = [
|
||||
{
|
||||
id: "document-plan-storybook",
|
||||
companyId: "company-storybook",
|
||||
issueId: "issue-storybook-1",
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Plan",
|
||||
"",
|
||||
"- Add issue-management stories for list, filters, detail, documents, runs, and workspace cards.",
|
||||
"- Use existing product components instead of mock-only approximations.",
|
||||
"- Verify the Storybook build after the fixture expansion.",
|
||||
].join("\n"),
|
||||
latestRevisionId: "revision-plan-3",
|
||||
latestRevisionNumber: 3,
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: "agent-codex",
|
||||
updatedByUserId: null,
|
||||
createdAt: recent(80),
|
||||
updatedAt: recent(8),
|
||||
},
|
||||
{
|
||||
id: "document-notes-storybook",
|
||||
companyId: "company-storybook",
|
||||
issueId: "issue-storybook-1",
|
||||
key: "notes",
|
||||
title: "Review Notes",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"# Review Notes",
|
||||
"",
|
||||
"- The issue list needs group headers and dense trailing columns.",
|
||||
"- The filters popover should show selected status, priority, and assignee filters.",
|
||||
"- Workspace copy must expose branch, path, and runtime status.",
|
||||
].join("\n"),
|
||||
latestRevisionId: "revision-notes-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-board",
|
||||
createdAt: recent(55),
|
||||
updatedAt: recent(12),
|
||||
},
|
||||
];
|
||||
|
||||
export const storybookContinuationHandoff: IssueDocument = {
|
||||
id: "document-continuation-storybook",
|
||||
companyId: "company-storybook",
|
||||
issueId: "issue-storybook-1",
|
||||
key: "continuation_summary",
|
||||
title: "Continuation handoff",
|
||||
format: "markdown",
|
||||
body: [
|
||||
"Current state: issue-management stories have the fixture surface mapped.",
|
||||
"",
|
||||
"Next action: run the Storybook build, inspect the issue management story, then request QA visual review if the build passes.",
|
||||
"",
|
||||
"Important files: `ui/storybook/stories/issue-management.stories.tsx` and `ui/storybook/fixtures/paperclipData.ts`.",
|
||||
].join("\n"),
|
||||
latestRevisionId: "revision-continuation-1",
|
||||
latestRevisionNumber: 1,
|
||||
createdByAgentId: "agent-codex",
|
||||
createdByUserId: null,
|
||||
updatedByAgentId: "agent-codex",
|
||||
updatedByUserId: null,
|
||||
createdAt: recent(18),
|
||||
updatedAt: recent(5),
|
||||
};
|
||||
|
||||
export const storybookIssueRuns: RunForIssue[] = [
|
||||
{
|
||||
runId: "run-storybook",
|
||||
status: "running",
|
||||
agentId: "agent-codex",
|
||||
adapterType: "codex_local",
|
||||
startedAt: recent(28).toISOString(),
|
||||
finishedAt: null,
|
||||
createdAt: recent(28).toISOString(),
|
||||
invocationSource: "manual",
|
||||
usageJson: { costCents: 142, inputTokens: 38400, outputTokens: 7200 },
|
||||
resultJson: null,
|
||||
logBytes: 62_400,
|
||||
livenessState: null,
|
||||
livenessReason: null,
|
||||
continuationAttempt: 1,
|
||||
lastUsefulActionAt: recent(3).toISOString(),
|
||||
nextAction: "Finish the issue-management Storybook and verify the static build.",
|
||||
},
|
||||
{
|
||||
runId: "run-storybook-qa",
|
||||
status: "succeeded",
|
||||
agentId: "agent-qa",
|
||||
adapterType: "claude_local",
|
||||
startedAt: recent(110).toISOString(),
|
||||
finishedAt: recent(94).toISOString(),
|
||||
createdAt: recent(110).toISOString(),
|
||||
invocationSource: "manual",
|
||||
usageJson: { costCents: 38, inputTokens: 12200, outputTokens: 1900 },
|
||||
resultJson: { stopReason: "completed" },
|
||||
logBytes: 18_400,
|
||||
livenessState: "advanced",
|
||||
livenessReason: "Verified the document editor empty state and left concrete screenshot notes.",
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: recent(96).toISOString(),
|
||||
nextAction: "Re-run after final story fixture additions land.",
|
||||
},
|
||||
{
|
||||
runId: "run-storybook-plan",
|
||||
status: "succeeded",
|
||||
agentId: "agent-codex",
|
||||
adapterType: "codex_local",
|
||||
startedAt: recent(210).toISOString(),
|
||||
finishedAt: recent(196).toISOString(),
|
||||
createdAt: recent(210).toISOString(),
|
||||
invocationSource: "scheduler",
|
||||
usageJson: { costCents: 24, inputTokens: 9100, outputTokens: 1100 },
|
||||
resultJson: { stopReason: "completed" },
|
||||
logBytes: 9800,
|
||||
livenessState: "plan_only",
|
||||
livenessReason: "Identified Storybook coverage gaps but did not edit files in that run.",
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: "Implement issue-management stories in the next heartbeat.",
|
||||
},
|
||||
];
|
||||
|
||||
export const storybookApprovals: Approval[] = [
|
||||
@@ -376,6 +889,10 @@ export const storybookEntityNameMap = new Map<string, string>([
|
||||
["issue:issue-storybook-1", "PAP-1641"],
|
||||
["issue:issue-storybook-2", "PAP-1528"],
|
||||
["issue:issue-storybook-3", "PAP-1602"],
|
||||
["issue:issue-storybook-4", "PAP-1668"],
|
||||
["issue:issue-storybook-5", "PAP-1669"],
|
||||
["issue:issue-storybook-6", "PAP-1670"],
|
||||
["issue:issue-storybook-7", "PAP-1591"],
|
||||
["approval:approval-budget", "Budget override"],
|
||||
["agent:agent-codex", "CodexCoder"],
|
||||
["agent:agent-qa", "QAChecker"],
|
||||
@@ -386,4 +903,64 @@ export const storybookEntityTitleMap = new Map<string, string>([
|
||||
["issue:issue-storybook-1", "Create super-detailed storybooks for the project"],
|
||||
["issue:issue-storybook-2", "Add budget hard-stop incident review"],
|
||||
["issue:issue-storybook-3", "QA invite flow on authenticated private mode"],
|
||||
["issue:issue-storybook-4", "Extract issue row density fixtures"],
|
||||
["issue:issue-storybook-5", "Review document editor empty states"],
|
||||
["issue:issue-storybook-6", "Publish static Storybook preview"],
|
||||
["issue:issue-storybook-7", "Confirm project budget override policy"],
|
||||
]);
|
||||
|
||||
export const storybookSidebarBadges: SidebarBadges = {
|
||||
inbox: 7,
|
||||
approvals: 2,
|
||||
failedRuns: 1,
|
||||
joinRequests: 1,
|
||||
};
|
||||
|
||||
export const storybookDashboardSummary: DashboardSummary = {
|
||||
companyId: "company-storybook",
|
||||
agents: {
|
||||
active: 3,
|
||||
running: 1,
|
||||
paused: 0,
|
||||
error: 0,
|
||||
},
|
||||
tasks: {
|
||||
open: 12,
|
||||
inProgress: 4,
|
||||
blocked: 1,
|
||||
done: 36,
|
||||
},
|
||||
costs: {
|
||||
monthSpendCents: 67_500,
|
||||
monthBudgetCents: 250_000,
|
||||
monthUtilizationPercent: 27,
|
||||
},
|
||||
pendingApprovals: 2,
|
||||
budgets: {
|
||||
activeIncidents: 1,
|
||||
pendingApprovals: 1,
|
||||
pausedAgents: 0,
|
||||
pausedProjects: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const storybookLiveRuns: LiveRunForIssue[] = [
|
||||
{
|
||||
id: "run-storybook",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: "issue_assigned",
|
||||
startedAt: recent(28).toISOString(),
|
||||
finishedAt: null,
|
||||
createdAt: recent(28).toISOString(),
|
||||
agentId: "agent-codex",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-storybook-1",
|
||||
livenessState: "advanced",
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: recent(3).toISOString(),
|
||||
nextAction: "Build fixture-backed navigation stories",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,757 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, RotateCcw, Settings2 } from "lucide-react";
|
||||
import {
|
||||
AGENT_ICON_NAMES,
|
||||
type Agent,
|
||||
type AgentRuntimeState,
|
||||
type CompanySecret,
|
||||
type EnvBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { ActiveAgentsPanel } from "@/components/ActiveAgentsPanel";
|
||||
import { AgentConfigForm, type CreateConfigValues } from "@/components/AgentConfigForm";
|
||||
import { defaultCreateValues } from "@/components/agent-config-defaults";
|
||||
import {
|
||||
DraftInput,
|
||||
DraftTextarea,
|
||||
Field,
|
||||
ToggleField,
|
||||
help,
|
||||
} from "@/components/agent-config-primitives";
|
||||
import { AgentIcon, AgentIconPicker } from "@/components/AgentIconPicker";
|
||||
import { AgentProperties } from "@/components/AgentProperties";
|
||||
import { RunButton, PauseResumeButton } from "@/components/AgentActionButtons";
|
||||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
import type { AdapterInfo } from "@/api/adapters";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { storybookAgents, storybookIssues } from "../fixtures/paperclipData";
|
||||
|
||||
const COMPANY_ID = "company-storybook";
|
||||
const now = new Date("2026-04-20T12:00:00.000Z");
|
||||
const recent = (minutesAgo: number) => new Date(now.getTime() - minutesAgo * 60_000);
|
||||
|
||||
function Section({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">{eyebrow}</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function agentWith(overrides: Partial<Agent>): Agent {
|
||||
return {
|
||||
...storybookAgents[0]!,
|
||||
...overrides,
|
||||
adapterConfig: {
|
||||
...storybookAgents[0]!.adapterConfig,
|
||||
...(overrides.adapterConfig ?? {}),
|
||||
},
|
||||
runtimeConfig: {
|
||||
...storybookAgents[0]!.runtimeConfig,
|
||||
...(overrides.runtimeConfig ?? {}),
|
||||
},
|
||||
permissions: {
|
||||
...storybookAgents[0]!.permissions,
|
||||
...(overrides.permissions ?? {}),
|
||||
},
|
||||
metadata: overrides.metadata ?? storybookAgents[0]!.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
const agentManagementAgents: Agent[] = [
|
||||
agentWith({
|
||||
id: "agent-codex",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
status: "running",
|
||||
icon: "code",
|
||||
role: "engineer",
|
||||
title: "Senior Product Engineer",
|
||||
reportsTo: "agent-cto",
|
||||
capabilities: "Owns full-stack product changes, Storybook coverage, and local verification loops.",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
command: "codex",
|
||||
model: "gpt-5.4",
|
||||
modelReasoningEffort: "high",
|
||||
search: true,
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
promptTemplate:
|
||||
"You are {{ agent.name }}. Work only on the checked-out issue, keep comments concise, and verify before handoff.",
|
||||
instructionsFilePath: "agents/codexcoder/AGENTS.md",
|
||||
extraArgs: ["--full-auto"],
|
||||
env: {
|
||||
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
|
||||
PAPERCLIP_TRACE: { type: "plain", value: "storybook" },
|
||||
} satisfies Record<string, EnvBinding>,
|
||||
timeoutSec: 7200,
|
||||
graceSec: 20,
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 900,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 30,
|
||||
maxConcurrentRuns: 2,
|
||||
},
|
||||
},
|
||||
lastHeartbeatAt: recent(2),
|
||||
updatedAt: recent(2),
|
||||
}),
|
||||
agentWith({
|
||||
id: "agent-qa",
|
||||
name: "QAChecker",
|
||||
urlKey: "qachecker",
|
||||
status: "idle",
|
||||
icon: "shield",
|
||||
role: "qa",
|
||||
title: "QA Engineer",
|
||||
reportsTo: "agent-cto",
|
||||
capabilities: "Runs targeted browser checks, release smoke tests, and visual Storybook reviews.",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {
|
||||
command: "claude",
|
||||
model: "claude-sonnet-4.5",
|
||||
effort: "medium",
|
||||
dangerouslySkipPermissions: false,
|
||||
chrome: true,
|
||||
instructionsFilePath: "agents/qachecker/AGENTS.md",
|
||||
env: {
|
||||
PLAYWRIGHT_HEADLESS: { type: "plain", value: "false" },
|
||||
} satisfies Record<string, EnvBinding>,
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
intervalSec: 1800,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 60,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
lastHeartbeatAt: recent(31),
|
||||
updatedAt: recent(31),
|
||||
}),
|
||||
agentWith({
|
||||
id: "agent-cto",
|
||||
name: "CTO",
|
||||
urlKey: "cto",
|
||||
status: "paused",
|
||||
icon: "crown",
|
||||
role: "cto",
|
||||
title: "CTO",
|
||||
reportsTo: null,
|
||||
capabilities: "Reviews engineering strategy, architecture risk, and high-impact implementation tradeoffs.",
|
||||
adapterType: "codex_local",
|
||||
pauseReason: "manual",
|
||||
pausedAt: recent(18),
|
||||
permissions: { canCreateAgents: true },
|
||||
adapterConfig: {
|
||||
command: "codex",
|
||||
model: "gpt-5.4",
|
||||
modelReasoningEffort: "xhigh",
|
||||
search: false,
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 3600,
|
||||
wakeOnDemand: false,
|
||||
cooldownSec: 120,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
lastHeartbeatAt: recent(57),
|
||||
updatedAt: recent(18),
|
||||
}),
|
||||
agentWith({
|
||||
id: "agent-observability",
|
||||
name: "OpsWatch",
|
||||
urlKey: "opswatch",
|
||||
status: "error",
|
||||
icon: "radar",
|
||||
role: "devops",
|
||||
title: "Runtime Operations Engineer",
|
||||
reportsTo: "agent-cto",
|
||||
capabilities: "Monitors local runners, workspace services, and stuck-run recovery signals.",
|
||||
adapterType: "http",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
adapterConfig: {
|
||||
webhookUrl: "https://ops.internal.example/heartbeat",
|
||||
payloadTemplateJson: JSON.stringify({ channel: "paperclip-storybook", priority: "normal" }, null, 2),
|
||||
env: {
|
||||
OPS_WEBHOOK_TOKEN: { type: "secret_ref", secretId: "secret-ops-webhook", version: 3 },
|
||||
} satisfies Record<string, EnvBinding>,
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
intervalSec: 600,
|
||||
wakeOnDemand: true,
|
||||
cooldownSec: 45,
|
||||
maxConcurrentRuns: 1,
|
||||
},
|
||||
},
|
||||
lastHeartbeatAt: recent(9),
|
||||
updatedAt: recent(9),
|
||||
}),
|
||||
];
|
||||
|
||||
const runtimeState: AgentRuntimeState = {
|
||||
agentId: "agent-codex",
|
||||
companyId: COMPANY_ID,
|
||||
adapterType: "codex_local",
|
||||
sessionId: "session-codex-storybook-management-20260420",
|
||||
sessionDisplayId: "codex-storybook-20260420",
|
||||
sessionParamsJson: {
|
||||
issueIdentifier: "PAP-1670",
|
||||
workspaceStrategy: "git_worktree",
|
||||
},
|
||||
stateJson: {
|
||||
currentIssue: "PAP-1670",
|
||||
workspace: "PAP-1641-create-super-detailed-storybooks-for-our-project",
|
||||
},
|
||||
lastRunId: "run-agent-management-live",
|
||||
lastRunStatus: "running",
|
||||
totalInputTokens: 286_400,
|
||||
totalOutputTokens: 42_900,
|
||||
totalCachedInputTokens: 113_200,
|
||||
totalCostCents: 4320,
|
||||
lastError: "Previous run lost its Storybook Vite websocket after a local server restart.",
|
||||
createdAt: recent(8_000),
|
||||
updatedAt: recent(2),
|
||||
};
|
||||
|
||||
const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: COMPANY_ID,
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 5,
|
||||
description: "Primary coding model key for local Codex agents.",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
createdAt: recent(21_000),
|
||||
updatedAt: recent(400),
|
||||
},
|
||||
{
|
||||
id: "secret-ops-webhook",
|
||||
companyId: COMPANY_ID,
|
||||
name: "OPS_WEBHOOK_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 3,
|
||||
description: "Webhook token for runtime observability callbacks.",
|
||||
createdByAgentId: "agent-cto",
|
||||
createdByUserId: null,
|
||||
createdAt: recent(12_000),
|
||||
updatedAt: recent(80),
|
||||
},
|
||||
];
|
||||
|
||||
const adapterFixtures: AdapterInfo[] = [
|
||||
{
|
||||
type: "codex_local",
|
||||
label: "Codex Local",
|
||||
source: "builtin",
|
||||
modelsCount: 3,
|
||||
loaded: true,
|
||||
disabled: false,
|
||||
capabilities: {
|
||||
supportsInstructionsBundle: true,
|
||||
supportsSkills: true,
|
||||
supportsLocalAgentJwt: true,
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "claude_local",
|
||||
label: "Claude Local",
|
||||
source: "builtin",
|
||||
modelsCount: 2,
|
||||
loaded: true,
|
||||
disabled: false,
|
||||
capabilities: {
|
||||
supportsInstructionsBundle: true,
|
||||
supportsSkills: true,
|
||||
supportsLocalAgentJwt: true,
|
||||
requiresMaterializedRuntimeSkills: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "http",
|
||||
label: "HTTP Webhook",
|
||||
source: "builtin",
|
||||
modelsCount: 0,
|
||||
loaded: true,
|
||||
disabled: false,
|
||||
capabilities: {
|
||||
supportsInstructionsBundle: false,
|
||||
supportsSkills: false,
|
||||
supportsLocalAgentJwt: false,
|
||||
requiresMaterializedRuntimeSkills: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const liveRuns: LiveRunForIssue[] = [
|
||||
{
|
||||
id: "run-agent-management-live",
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "issue_assigned",
|
||||
startedAt: recent(8).toISOString(),
|
||||
finishedAt: null,
|
||||
createdAt: recent(8).toISOString(),
|
||||
agentId: "agent-codex",
|
||||
agentName: "CodexCoder",
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-storybook-1",
|
||||
livenessState: "advanced",
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: recent(1).toISOString(),
|
||||
nextAction: "Run a targeted Storybook static build.",
|
||||
},
|
||||
{
|
||||
id: "run-agent-management-queued",
|
||||
status: "queued",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
createdAt: recent(3).toISOString(),
|
||||
agentId: "agent-qa",
|
||||
agentName: "QAChecker",
|
||||
adapterType: "claude_local",
|
||||
issueId: "issue-storybook-3",
|
||||
livenessState: null,
|
||||
livenessReason: "Waiting for current visual review to finish.",
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: "Open the Storybook preview and capture mobile screenshots.",
|
||||
},
|
||||
{
|
||||
id: "run-agent-management-succeeded",
|
||||
status: "succeeded",
|
||||
invocationSource: "timer",
|
||||
triggerDetail: "scheduler",
|
||||
startedAt: recent(48).toISOString(),
|
||||
finishedAt: recent(39).toISOString(),
|
||||
createdAt: recent(48).toISOString(),
|
||||
agentId: "agent-cto",
|
||||
agentName: "CTO",
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-storybook-2",
|
||||
livenessState: "completed",
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: recent(39).toISOString(),
|
||||
nextAction: null,
|
||||
},
|
||||
{
|
||||
id: "run-agent-management-failed",
|
||||
status: "failed",
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "routine",
|
||||
startedAt: recent(76).toISOString(),
|
||||
finishedAt: recent(70).toISOString(),
|
||||
createdAt: recent(76).toISOString(),
|
||||
agentId: "agent-observability",
|
||||
agentName: "OpsWatch",
|
||||
adapterType: "http",
|
||||
issueId: null,
|
||||
livenessState: "blocked",
|
||||
livenessReason: "Webhook returned 503 during local runtime restart.",
|
||||
continuationAttempt: 1,
|
||||
lastUsefulActionAt: recent(72).toISOString(),
|
||||
nextAction: "Retry after runtime service health check recovers.",
|
||||
},
|
||||
];
|
||||
|
||||
function StorybookQueryFixtures({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
queryClient.setQueryData(queryKeys.agents.list(COMPANY_ID), agentManagementAgents);
|
||||
queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets);
|
||||
queryClient.setQueryData(queryKeys.adapters.all, adapterFixtures);
|
||||
queryClient.setQueryData(queryKeys.issues.list(COMPANY_ID), storybookIssues);
|
||||
queryClient.setQueryData([...queryKeys.issues.list(COMPANY_ID), "with-routine-executions"], storybookIssues);
|
||||
queryClient.setQueryData([...queryKeys.liveRuns(COMPANY_ID), "dashboard"], liveRuns);
|
||||
queryClient.setQueryData(queryKeys.instance.generalSettings, { censorUsernameInLogs: 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" },
|
||||
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
|
||||
]);
|
||||
queryClient.setQueryData(queryKeys.agents.detectModel(COMPANY_ID, "codex_local"), {
|
||||
model: "gpt-5.4",
|
||||
provider: "openai",
|
||||
source: "config",
|
||||
candidates: ["gpt-5.4", "gpt-5.4-mini"],
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.agents.adapterModels(COMPANY_ID, "claude_local"), [
|
||||
{ id: "claude-sonnet-4.5", label: "Claude Sonnet 4.5" },
|
||||
{ id: "claude-opus-4.1", label: "Claude Opus 4.1" },
|
||||
]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function AgentConfigFormStory() {
|
||||
const [values, setValues] = useState<CreateConfigValues>({
|
||||
...defaultCreateValues,
|
||||
adapterType: "codex_local",
|
||||
command: "codex",
|
||||
model: "gpt-5.4",
|
||||
thinkingEffort: "high",
|
||||
search: true,
|
||||
dangerouslyBypassSandbox: true,
|
||||
promptTemplate:
|
||||
"You are {{ agent.name }}. Read the assigned issue, make a small verified change, and update the task.",
|
||||
extraArgs: "--full-auto, --search",
|
||||
envBindings: {
|
||||
OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" },
|
||||
PAPERCLIP_TRACE: { type: "plain", value: "storybook" },
|
||||
},
|
||||
runtimeServicesJson: JSON.stringify(
|
||||
[
|
||||
{
|
||||
name: "storybook",
|
||||
command: "pnpm storybook",
|
||||
url: "http://localhost:6006",
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
heartbeatEnabled: true,
|
||||
intervalSec: 900,
|
||||
});
|
||||
|
||||
return (
|
||||
<AgentConfigForm
|
||||
mode="create"
|
||||
values={values}
|
||||
onChange={(patch) => setValues((current) => ({ ...current, ...patch }))}
|
||||
sectionLayout="cards"
|
||||
showAdapterTestEnvironmentButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPickerMatrix() {
|
||||
const [selectedIcon, setSelectedIcon] = useState("code");
|
||||
const visibleIcons = AGENT_ICON_NAMES.slice(0, 28);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Selected identity</CardTitle>
|
||||
<CardDescription>The real picker trigger updates the selected fixture state.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-border bg-accent/40">
|
||||
<AgentIcon icon={selectedIcon} className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">StorybookEngineer</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{selectedIcon}</div>
|
||||
</div>
|
||||
</div>
|
||||
<AgentIconPicker value={selectedIcon} onChange={setSelectedIcon}>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Open icon picker
|
||||
</Button>
|
||||
</AgentIconPicker>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="rounded-xl border border-border bg-background/70 p-4">
|
||||
<div className="grid grid-cols-7 gap-2 sm:grid-cols-10 md:grid-cols-14">
|
||||
{visibleIcons.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => setSelectedIcon(name)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg border border-border transition-colors hover:bg-accent",
|
||||
selectedIcon === name && "border-primary bg-primary/10 text-primary ring-1 ring-primary",
|
||||
)}
|
||||
>
|
||||
<AgentIcon icon={name} className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActionsMatrix() {
|
||||
const actionAgents = [
|
||||
agentManagementAgents[0]!,
|
||||
agentManagementAgents[1]!,
|
||||
agentManagementAgents[2]!,
|
||||
agentManagementAgents[3]!,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
{actionAgents.map((agent) => {
|
||||
const paused = agent.status === "paused";
|
||||
const runDisabled = agent.status === "running" || agent.status === "paused";
|
||||
const restartDisabled = agent.status === "paused";
|
||||
|
||||
return (
|
||||
<Card key={agent.id} className="shadow-none">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-accent/40">
|
||||
<AgentIcon icon={agent.icon} className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<CardTitle className="text-base">{agent.name}</CardTitle>
|
||||
<CardDescription>{agent.title}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={agent.status === "error" ? "destructive" : "outline"}>{agent.status}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
<PauseResumeButton
|
||||
isPaused={paused}
|
||||
onPause={() => undefined}
|
||||
onResume={() => undefined}
|
||||
disabled={agent.status === "running"}
|
||||
/>
|
||||
<RunButton
|
||||
label={agent.status === "running" ? "Running" : "Run now"}
|
||||
onClick={() => undefined}
|
||||
disabled={runDisabled}
|
||||
/>
|
||||
<Button variant="outline" size="sm" disabled={restartDisabled}>
|
||||
<RotateCcw className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Restart</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit3 className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Edit</span>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<div className="space-y-4 rounded-xl border border-border bg-background/70 p-4">
|
||||
<Field label="Text field" hint={help.model}>
|
||||
<DraftInput
|
||||
value={textValue}
|
||||
onCommit={setTextValue}
|
||||
immediate
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 font-mono text-sm outline-none"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Select field" hint={help.workspaceStrategy}>
|
||||
<Select value={selectValue} onValueChange={setSelectValue}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Workspace strategy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="project_primary">Project primary</SelectItem>
|
||||
<SelectItem value="git_worktree">Git worktree</SelectItem>
|
||||
<SelectItem value="agent_home">Agent home</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<ToggleField
|
||||
label="Toggle field"
|
||||
hint={help.wakeOnDemand}
|
||||
checked={toggleValue}
|
||||
onChange={setToggleValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-background/70 p-4">
|
||||
<Field label="JSON editor" hint={help.runtimeServicesJson}>
|
||||
<DraftTextarea
|
||||
value={jsonValue}
|
||||
onCommit={setJsonValue}
|
||||
immediate
|
||||
minRows={10}
|
||||
placeholder='{"runtimeServices":[]}'
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentManagementStories() {
|
||||
return (
|
||||
<StorybookQueryFixtures>
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">
|
||||
<section className="paperclip-story__frame p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<div className="paperclip-story__label">Agent management</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Agent details, controls, and config surfaces</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">adapter config</Badge>
|
||||
<Badge variant="outline">runtime policy</Badge>
|
||||
<Badge variant="outline">env bindings</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section eyebrow="AgentProperties" title="Full detail panel with runtime and reporting data">
|
||||
<div className="grid gap-5 lg:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-lg border border-border bg-accent/40">
|
||||
<AgentIcon icon={agentManagementAgents[0]!.icon} className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<CardTitle>{agentManagementAgents[0]!.name}</CardTitle>
|
||||
<CardDescription>{agentManagementAgents[0]!.capabilities}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AgentProperties agent={agentManagementAgents[0]!} runtimeState={runtimeState} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="rounded-xl border border-border bg-background/70 p-5">
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">session populated</Badge>
|
||||
<Badge variant="secondary">last error shown</Badge>
|
||||
<Badge variant="secondary">manager lookup seeded</Badge>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="text-xs text-muted-foreground">Budget</div>
|
||||
<div className="mt-1 font-mono">${(agentManagementAgents[0]!.budgetMonthlyCents / 100).toFixed(0)} / month</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="text-xs text-muted-foreground">Spent</div>
|
||||
<div className="mt-1 font-mono">${(agentManagementAgents[0]!.spentMonthlyCents / 100).toFixed(0)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="text-xs text-muted-foreground">Instructions</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">
|
||||
{String(agentManagementAgents[0]!.adapterConfig.instructionsFilePath)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="text-xs text-muted-foreground">Runtime policy</div>
|
||||
<div className="mt-1 font-mono text-xs">heartbeat / 900s / max 2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="AgentConfigForm" title="Adapter selection, runtime config, and env vars">
|
||||
<div className="max-w-4xl">
|
||||
<AgentConfigFormStory />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="AgentIconPicker" title="Available icon grid with selected state">
|
||||
<IconPickerMatrix />
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="AgentActionButtons" title="Pause, resume, restart, edit, and run actions by state">
|
||||
<AgentActionsMatrix />
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="ActiveAgentsPanel" title="Mixed live, queued, succeeded, and failed agent runs">
|
||||
<ActiveAgentsPanel companyId={COMPANY_ID} />
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="agent-config-primitives" title="Individual text, select, toggle, and JSON field types">
|
||||
<ConfigPrimitivesStory />
|
||||
</Section>
|
||||
|
||||
<Separator />
|
||||
</main>
|
||||
</div>
|
||||
</StorybookQueryFixtures>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof AgentManagementStories>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ManagementMatrix: Story = {};
|
||||
@@ -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 (
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">{eyebrow}</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof tone, string>;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={`rounded-lg border px-3 py-2 ${toneClasses[tone]}`}>
|
||||
<div className="text-sm font-medium text-foreground">{title}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, CostByProviderModel[]> = {
|
||||
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<string, CostWindowSpendRow[]> = {
|
||||
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 (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">
|
||||
<section className="paperclip-story__frame p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<div className="paperclip-story__label">Budget and finance</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Spend controls, quotas, and accounting surfaces</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">healthy</Badge>
|
||||
<Badge variant="outline">warning</Badge>
|
||||
<Badge variant="outline">critical</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Section eyebrow="Incidents" title="BudgetIncidentCard resolution, pending, and escalated states">
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<CaseFrame title="Resolved raise-and-resume" detail="Approval approved and incident resolved after the budget was raised." tone="healthy">
|
||||
<BudgetIncidentCard
|
||||
incident={budgetIncidents[0]!}
|
||||
onKeepPaused={() => undefined}
|
||||
onRaiseAndResume={() => undefined}
|
||||
/>
|
||||
</CaseFrame>
|
||||
<CaseFrame title="Pending board decision" detail="Project execution is paused while a budget approval waits for review." tone="warning">
|
||||
<BudgetIncidentCard
|
||||
incident={budgetIncidents[1]!}
|
||||
onKeepPaused={() => undefined}
|
||||
onRaiseAndResume={() => undefined}
|
||||
/>
|
||||
</CaseFrame>
|
||||
<CaseFrame title="Escalated hard stop" detail="Company spend exceeded the cap and the first approval needs revision." tone="critical">
|
||||
<BudgetIncidentCard
|
||||
incident={budgetIncidents[2]!}
|
||||
onKeepPaused={() => undefined}
|
||||
onRaiseAndResume={() => undefined}
|
||||
/>
|
||||
</CaseFrame>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Sidebar" title="BudgetSidebarMarker healthy, warning, and critical indicators">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{sidebarMarkers.map((marker) => {
|
||||
const Icon = marker.icon;
|
||||
return (
|
||||
<div key={marker.level} className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{marker.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{marker.detail}</div>
|
||||
</div>
|
||||
<BudgetSidebarMarker level={marker.level} title={`${marker.label} budget indicator`} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Providers" title="ProviderQuotaCard usage bars and subscription quota windows">
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
<CaseFrame title="Healthy provider" detail="Anthropic subscription usage still has room in short and weekly windows." tone="healthy">
|
||||
<ProviderQuotaCard
|
||||
provider="anthropic"
|
||||
rows={providerRowsByProvider.anthropic}
|
||||
budgetMonthlyCents={250_000}
|
||||
totalCompanySpendCents={83_000}
|
||||
weekSpendCents={3_870}
|
||||
windowRows={providerWindowRows.anthropic}
|
||||
showDeficitNotch={false}
|
||||
quotaWindows={claudeQuotaWindows}
|
||||
quotaSource="anthropic-oauth"
|
||||
/>
|
||||
</CaseFrame>
|
||||
<CaseFrame title="Warning provider" detail="Codex weekly usage is high and subscription overage has started." tone="warning">
|
||||
<ProviderQuotaCard
|
||||
provider="openai"
|
||||
rows={providerRowsByProvider.openai}
|
||||
budgetMonthlyCents={250_000}
|
||||
totalCompanySpendCents={218_000}
|
||||
weekSpendCents={10_430}
|
||||
windowRows={providerWindowRows.openai}
|
||||
showDeficitNotch={false}
|
||||
quotaWindows={codexQuotaWindows}
|
||||
quotaSource="codex-rpc"
|
||||
/>
|
||||
</CaseFrame>
|
||||
<CaseFrame title="Critical biller" detail="OpenRouter credits are beyond the monthly allocation and show deficit treatment." tone="critical">
|
||||
<ProviderQuotaCard
|
||||
provider="openrouter"
|
||||
rows={providerRowsByProvider.openrouter}
|
||||
budgetMonthlyCents={250_000}
|
||||
totalCompanySpendCents={286_000}
|
||||
weekSpendCents={14_630}
|
||||
windowRows={providerWindowRows.openrouter}
|
||||
showDeficitNotch
|
||||
quotaWindows={[
|
||||
{ label: "Credits", usedPercent: 97, resetsAt: null, valueLabel: "$8.17 remaining", detail: "Critical credit balance before next top-up." },
|
||||
{ label: "Requests", usedPercent: 89, resetsAt: at(-520).toISOString(), valueLabel: null, detail: "Warning-level gateway request window." },
|
||||
]}
|
||||
quotaSource="openrouter"
|
||||
/>
|
||||
</CaseFrame>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Accounting" title="AccountingModelCard cost allocation reference">
|
||||
<AccountingModelCard />
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Billers" title="BillerSpendCard period comparison and upstream provider split">
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{billerSpendRows.map((entry) => (
|
||||
<CaseFrame
|
||||
key={entry.row.biller}
|
||||
title={`${entry.state[0]!.toUpperCase()}${entry.state.slice(1)} allocation`}
|
||||
detail="The card compares period spend, weekly spend, billing types, and upstream providers."
|
||||
tone={entry.state}
|
||||
>
|
||||
<BillerSpendCard
|
||||
row={entry.row}
|
||||
weekSpendCents={entry.weekSpendCents}
|
||||
budgetMonthlyCents={250_000}
|
||||
totalCompanySpendCents={entry.totalCompanySpendCents}
|
||||
providerRows={entry.providerRows}
|
||||
/>
|
||||
</CaseFrame>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Finance" title="FinanceBillerCard, FinanceKindCard, and invoice timeline">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_390px]">
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{financeBillerRows.map((row) => (
|
||||
<FinanceBillerCard key={row.biller} row={row} />
|
||||
))}
|
||||
</div>
|
||||
<FinanceTimelineCard rows={financeTimelineRows} />
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<FinanceKindCard rows={financeKindRows} />
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ReceiptText className="h-4 w-4" />
|
||||
Category fixtures
|
||||
</CardTitle>
|
||||
<CardDescription>Compute, storage, and API-style finance rows are represented by provisioned capacity, log storage, and inference charges.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-3 xl:grid-cols-1">
|
||||
{[
|
||||
{ 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 (
|
||||
<div key={item.label} className="flex items-center justify-between gap-3 border border-border p-3">
|
||||
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="font-mono font-medium">{item.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section eyebrow="Subscriptions" title="ClaudeSubscriptionPanel and CodexSubscriptionPanel status windows">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ClaudeSubscriptionPanel windows={claudeQuotaWindows} source="anthropic-oauth" />
|
||||
<CodexSubscriptionPanel windows={codexQuotaWindows} source="codex-rpc" />
|
||||
</div>
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<ClaudeSubscriptionPanel
|
||||
windows={[]}
|
||||
source="claude-cli"
|
||||
error="Claude CLI quota polling timed out after 10s. Last successful sample was 18 minutes ago."
|
||||
/>
|
||||
<CodexSubscriptionPanel
|
||||
windows={[]}
|
||||
source="codex-wham"
|
||||
error="Codex app server is unavailable, so live subscription windows cannot be refreshed."
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof BudgetFinanceMatrix>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullMatrix: Story = {};
|
||||
@@ -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 (
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="paperclip-story__label">{eyebrow}</div>
|
||||
<div className="mt-1 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatePanel({
|
||||
label,
|
||||
detail,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
detail?: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg border border-border bg-background/70 p-4">
|
||||
<div className="mb-3 flex min-h-6 flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{detail ? <div className="mt-1 text-xs leading-5 text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
{disabled ? <Badge variant="outline">disabled</Badge> : null}
|
||||
</div>
|
||||
<div className={disabled ? "pointer-events-none opacity-55" : undefined}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, EnvBinding> = {
|
||||
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 (
|
||||
<Section
|
||||
eyebrow="MarkdownEditor"
|
||||
title="Composer states with content, read-only mode, and action buttons"
|
||||
description="The editor is controlled in all examples so reviewers can type, trigger mentions, and see command insertion behavior."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<StatePanel label="Empty" detail="Placeholder, border, and mention-ready empty state.">
|
||||
<MarkdownEditor
|
||||
value={emptyMarkdown}
|
||||
onChange={setEmptyMarkdown}
|
||||
placeholder="Write a task update..."
|
||||
mentions={editorMentions}
|
||||
/>
|
||||
</StatePanel>
|
||||
<StatePanel label="Filled" detail="Long-form markdown with a table and fenced code block.">
|
||||
<MarkdownEditor value={filledMarkdown} onChange={setFilledMarkdown} mentions={editorMentions} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Read-only" detail="Uses the editor rendering path without accepting edits." disabled>
|
||||
<MarkdownEditor value={reviewMarkdown} onChange={() => undefined} readOnly mentions={editorMentions} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Toolbar actions" detail="External controls exercise insertion actions around the editor.">
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setActionMarkdown((value) => `${value}\n\n## Next action\n`)}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Heading
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setActionMarkdown((value) => `${value}\n\n- Verify typecheck\n- Build Storybook\n`)}>
|
||||
<ListPlus className="mr-2 h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setActionMarkdown((value) => `${value}\n\n| Field | State |\n| --- | --- |\n| Forms | Ready |\n`)}>
|
||||
<Table2 className="mr-2 h-4 w-4" />
|
||||
Table
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setActionMarkdown((value) => `${value}\n\n\`\`\`sh\npnpm build-storybook\n\`\`\`\n`)}>
|
||||
<Code2 className="mr-2 h-4 w-4" />
|
||||
Code
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setActionMarkdown("Draft an update for @CodexCoder and /check-pr.")}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<MarkdownEditor value={actionMarkdown} onChange={setActionMarkdown} mentions={editorMentions} />
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownBodyGallery() {
|
||||
return (
|
||||
<Section
|
||||
eyebrow="MarkdownBody"
|
||||
title="Rendered markdown for task documents and comments"
|
||||
description="GFM coverage includes headings, task lists, links, tables, and code blocks in the app's prose wrapper."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<StatePanel label="Filled markdown" detail="Mixed document syntax with code and table overflow handling.">
|
||||
<MarkdownBody linkIssueReferences={false}>{reviewMarkdown}</MarkdownBody>
|
||||
</StatePanel>
|
||||
<div className="space-y-4">
|
||||
<StatePanel label="Empty">
|
||||
<MarkdownBody>{""}</MarkdownBody>
|
||||
<p className="text-sm text-muted-foreground">No markdown body content.</p>
|
||||
</StatePanel>
|
||||
<StatePanel label="Disabled container" disabled>
|
||||
<MarkdownBody linkIssueReferences={false}>A read-only preview can be dimmed by the parent surface.</MarkdownBody>
|
||||
</StatePanel>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonSchemaFormGallery() {
|
||||
const [filledValues, setFilledValues] = useState<Record<string, unknown>>(validAdapterValues);
|
||||
const [errorValues, setErrorValues] = useState<Record<string, unknown>>(invalidAdapterValues);
|
||||
|
||||
return (
|
||||
<Section
|
||||
eyebrow="JsonSchemaForm"
|
||||
title="Generated adapter configuration forms"
|
||||
description="The schema exercises strings, enums, secrets, numbers, booleans, arrays, objects, validation errors, and disabled controls."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<StatePanel label="Filled">
|
||||
<JsonSchemaForm schema={adapterSchema} values={filledValues} onChange={setFilledValues} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Validation errors">
|
||||
<JsonSchemaForm schema={adapterSchema} values={errorValues} onChange={setErrorValues} errors={adapterErrors} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Empty schema">
|
||||
<JsonSchemaForm schema={{ type: "object", properties: {} }} values={{}} onChange={() => undefined} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Disabled" disabled>
|
||||
<JsonSchemaForm schema={adapterSchema} values={filledValues} onChange={() => undefined} disabled />
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Section eyebrow="InlineEditor" title="Inline title and description editing">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatePanel label="Title editing" detail="Click the title to edit and press Enter to save.">
|
||||
<InlineEditor value={title} onSave={setTitle} as="h2" className="text-2xl font-semibold" />
|
||||
</StatePanel>
|
||||
<StatePanel label="Description editing" detail="Multiline markdown editor with autosave affordance.">
|
||||
<InlineEditor value={description} onSave={setDescription} as="p" multiline nullable />
|
||||
</StatePanel>
|
||||
<StatePanel label="Empty nullable title" detail="Placeholder state for optional inline fields.">
|
||||
<InlineEditor value={emptyTitle} onSave={setEmptyTitle} as="h2" nullable placeholder="Untitled issue" />
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvVarEditorGallery() {
|
||||
const [emptyEnv, setEmptyEnv] = useState<Record<string, EnvBinding>>({});
|
||||
const [env, setEnv] = useState<Record<string, EnvBinding>>(filledEnv);
|
||||
const createSecret = async (name: string): Promise<CompanySecret> => ({
|
||||
...storybookSecrets[0]!,
|
||||
id: `secret-${name.toLowerCase()}`,
|
||||
name,
|
||||
latestVersion: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section eyebrow="EnvVarEditor" title="Runtime environment bindings">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatePanel label="Empty add row" detail="Trailing blank row is the add state.">
|
||||
<EnvVarEditor value={emptyEnv} secrets={storybookSecrets} onCreateSecret={createSecret} onChange={(next) => setEmptyEnv(next ?? {})} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Plain and secret values" detail="Filled rows show edit, seal, secret select, and remove controls.">
|
||||
<EnvVarEditor value={env} secrets={storybookSecrets} onCreateSecret={createSecret} onChange={(next) => setEnv(next ?? {})} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Disabled shell" disabled>
|
||||
<EnvVarEditor value={filledEnv} secrets={storybookSecrets} onCreateSecret={createSecret} onChange={() => undefined} />
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleEditorGallery() {
|
||||
const [emptyCron, setEmptyCron] = useState("");
|
||||
const [weeklyCron, setWeeklyCron] = useState("30 9 * * 1");
|
||||
const [customCron, setCustomCron] = useState("15 16 1 * *");
|
||||
|
||||
return (
|
||||
<Section eyebrow="ScheduleEditor" title="Cron picker with human-readable previews">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<StatePanel label="Empty default" detail={describeSchedule(emptyCron)}>
|
||||
<ScheduleEditor value={emptyCron} onChange={setEmptyCron} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Weekly filled" detail={describeSchedule(weeklyCron)}>
|
||||
<ScheduleEditor value={weeklyCron} onChange={setWeeklyCron} />
|
||||
</StatePanel>
|
||||
<StatePanel label="Custom disabled preview" detail={describeSchedule(customCron)} disabled>
|
||||
<ScheduleEditor value={customCron} onChange={setCustomCron} />
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutineVariablesGallery() {
|
||||
const [variables, setVariables] = useState<RoutineVariable[]>(routineVariables);
|
||||
|
||||
return (
|
||||
<Section
|
||||
eyebrow="RoutineVariablesEditor"
|
||||
title="Detected runtime variable definitions"
|
||||
description="Variable rows are synced from title and instructions placeholders, then configured with types, defaults, required flags, and select options."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<StatePanel label="Detected variables">
|
||||
<RoutineVariablesEditor
|
||||
title="Review {{repo}} at {{priority}} priority"
|
||||
description="Include browser QA: {{include_browser}}\n\nOperator notes: {{notes}}"
|
||||
value={variables}
|
||||
onChange={setVariables}
|
||||
/>
|
||||
</StatePanel>
|
||||
<div className="space-y-4">
|
||||
<StatePanel label="Empty hint">
|
||||
<RoutineVariablesHint />
|
||||
</StatePanel>
|
||||
<StatePanel label="Disabled shell" disabled>
|
||||
<RoutineVariablesEditor
|
||||
title="Review {{repo}}"
|
||||
description="Use {{priority}} priority"
|
||||
value={variables.slice(0, 2)}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</StatePanel>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerGallery() {
|
||||
const [issue, setIssue] = useState(() =>
|
||||
createIssue({
|
||||
executionPolicy: buildExecutionPolicy({
|
||||
reviewerValues: ["agent:agent-qa"],
|
||||
approverValues: ["user:user-board"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [manager, setManager] = useState<string | null>("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 (
|
||||
<Section
|
||||
eyebrow="Pickers"
|
||||
title="Execution participants, reporting hierarchy, and inline entity selection"
|
||||
description="Closed trigger states stay compact, while the dropdowns are interactive for search and selection review."
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<StatePanel label="ExecutionParticipantPicker" detail="Review and approval participants share the same policy object.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ExecutionParticipantPicker
|
||||
issue={issue}
|
||||
stageType="review"
|
||||
agents={storybookAgents}
|
||||
currentUserId="user-board"
|
||||
onUpdate={(patch) => setIssue((current) => ({ ...current, ...patch }))}
|
||||
/>
|
||||
<ExecutionParticipantPicker
|
||||
issue={issue}
|
||||
stageType="approval"
|
||||
agents={storybookAgents}
|
||||
currentUserId="user-board"
|
||||
onUpdate={(patch) => setIssue((current) => ({ ...current, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
</StatePanel>
|
||||
<StatePanel label="ReportsToPicker" detail="Selected manager, CEO disabled state, and filtered hierarchy choices.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ReportsToPicker agents={agentsWithTerminated} value={manager} onChange={setManager} excludeAgentIds={["agent-codex"]} />
|
||||
<ReportsToPicker agents={agentsWithTerminated} value={null} onChange={() => undefined} disabled />
|
||||
</div>
|
||||
</StatePanel>
|
||||
<StatePanel label="InlineEntitySelector" detail="Search/select dropdown for issue, project, and agent entities.">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<InlineEntitySelector
|
||||
value={selectorValue}
|
||||
options={entityOptions}
|
||||
recentOptionIds={["issue-1672"]}
|
||||
placeholder="Entity"
|
||||
noneLabel="No entity"
|
||||
searchPlaceholder="Search entities..."
|
||||
emptyMessage="No matching entity."
|
||||
onChange={setSelectorValue}
|
||||
/>
|
||||
<div className="pointer-events-none opacity-55">
|
||||
<InlineEntitySelector
|
||||
value=""
|
||||
options={entityOptions}
|
||||
placeholder="Entity"
|
||||
noneLabel="No entity"
|
||||
searchPlaceholder="Search entities..."
|
||||
emptyMessage="No matching entity."
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StatePanel>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function FormsEditorsShowcase() {
|
||||
return (
|
||||
<StoryShell>
|
||||
<section className="paperclip-story__frame p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<div className="paperclip-story__label">Forms and editors</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Paperclip form controls under realistic state</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">empty</Badge>
|
||||
<Badge variant="outline">filled</Badge>
|
||||
<Badge variant="outline">validation</Badge>
|
||||
<Badge variant="outline">disabled</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MarkdownEditorGallery />
|
||||
<MarkdownBodyGallery />
|
||||
<JsonSchemaFormGallery />
|
||||
<InlineEditorGallery />
|
||||
<EnvVarEditorGallery />
|
||||
<ScheduleEditorGallery />
|
||||
<RoutineVariablesGallery />
|
||||
<PickerGallery />
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function RoutineRunDialogStory() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [submitted, setSubmitted] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section
|
||||
eyebrow="RoutineRunVariablesDialog"
|
||||
title="Manual routine run configuration"
|
||||
description="The dialog collects runtime variables, the target assignee, and optional project context before creating the run issue."
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={() => setOpen(true)}>Open run dialog</Button>
|
||||
{submitted ? (
|
||||
<pre className="max-w-full overflow-x-auto rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
|
||||
{JSON.stringify(submitted, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Submit the dialog to inspect the payload.</span>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
<RoutineRunVariablesDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
companyId="company-storybook"
|
||||
routineName="Weekly release review"
|
||||
projects={[storybookProject]}
|
||||
agents={storybookAgents}
|
||||
defaultProjectId="project-board-ui"
|
||||
defaultAssigneeAgentId="agent-codex"
|
||||
variables={routineVariables}
|
||||
isPending={false}
|
||||
onSubmit={(data) => {
|
||||
setSubmitted({ ...data });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof meta>;
|
||||
|
||||
export const AllFormsAndEditors: Story = {
|
||||
name: "All Forms And Editors",
|
||||
render: () => <FormsEditorsShowcase />,
|
||||
};
|
||||
|
||||
export const RoutineRunVariablesDialogOpen: Story = {
|
||||
name: "Routine Run Variables Dialog",
|
||||
render: () => <RoutineRunDialogStory />,
|
||||
};
|
||||
Reference in new issue
Block a user