mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
feat(ui): add chat-comments and data-viz-misc stories
New story files for chat/comments (713 lines) and data visualization/ miscellaneous (750 lines) components. Updates fixtures and overview story to reflect expanded coverage. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
09a97605f9
commit
ae627978fd
4 files changed
+1819
-37
No files matched your search
@@ -7,11 +7,13 @@ import type {
|
||||
Company,
|
||||
DashboardSummary,
|
||||
ExecutionWorkspace,
|
||||
Goal,
|
||||
Issue,
|
||||
IssueDocument,
|
||||
IssueLabel,
|
||||
Project,
|
||||
SidebarBadges,
|
||||
WorkspaceRuntimeService,
|
||||
} from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "@/api/activity";
|
||||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
@@ -219,7 +221,137 @@ function labelsFor(ids: string[]) {
|
||||
return ids.map((id) => storybookIssueLabelMap.get(id)).filter((label): label is IssueLabel => Boolean(label));
|
||||
}
|
||||
|
||||
export const storybookProjectWorkspaces = [
|
||||
export const storybookGoals: Goal[] = [
|
||||
{
|
||||
id: "goal-company",
|
||||
companyId: "company-storybook",
|
||||
title: "Build Paperclip",
|
||||
description: "Make Paperclip the control plane operators trust for autonomous AI companies.",
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: "agent-cto",
|
||||
createdAt: recent(30_000),
|
||||
updatedAt: recent(8),
|
||||
},
|
||||
{
|
||||
id: "goal-board-ux",
|
||||
companyId: "company-storybook",
|
||||
title: "Tighten board operator visibility",
|
||||
description: "Every project, goal, and workspace surface should reveal ownership, progress, and runtime state at a glance.",
|
||||
level: "team",
|
||||
status: "active",
|
||||
parentId: "goal-company",
|
||||
ownerAgentId: "agent-codex",
|
||||
createdAt: recent(19_000),
|
||||
updatedAt: recent(18),
|
||||
},
|
||||
{
|
||||
id: "goal-agent-runtime",
|
||||
companyId: "company-storybook",
|
||||
title: "Stabilize agent runtime loops",
|
||||
description: "Keep local and isolated workspaces predictable while preserving operator control.",
|
||||
level: "team",
|
||||
status: "planned",
|
||||
parentId: "goal-company",
|
||||
ownerAgentId: "agent-cto",
|
||||
createdAt: recent(17_500),
|
||||
updatedAt: recent(60),
|
||||
},
|
||||
{
|
||||
id: "goal-storybook",
|
||||
companyId: "company-storybook",
|
||||
title: "Complete Storybook review coverage",
|
||||
description: "Capture dense board UI states in fixture-backed stories before release review.",
|
||||
level: "task",
|
||||
status: "active",
|
||||
parentId: "goal-board-ux",
|
||||
ownerAgentId: "agent-codex",
|
||||
createdAt: recent(9_000),
|
||||
updatedAt: recent(3),
|
||||
},
|
||||
{
|
||||
id: "goal-budget-safety",
|
||||
companyId: "company-storybook",
|
||||
title: "Enforce spend guardrails",
|
||||
description: "Budget hard stops should be visible before they surprise operators.",
|
||||
level: "agent",
|
||||
status: "achieved",
|
||||
parentId: "goal-agent-runtime",
|
||||
ownerAgentId: "agent-qa",
|
||||
createdAt: recent(12_000),
|
||||
updatedAt: recent(120),
|
||||
},
|
||||
{
|
||||
id: "goal-archived-import",
|
||||
companyId: "company-storybook",
|
||||
title: "Retire old import wizard",
|
||||
description: "Legacy import wizard work is preserved for audit only.",
|
||||
level: "task",
|
||||
status: "cancelled",
|
||||
parentId: "goal-board-ux",
|
||||
ownerAgentId: null,
|
||||
createdAt: recent(24_000),
|
||||
updatedAt: recent(2_500),
|
||||
},
|
||||
];
|
||||
|
||||
function createRuntimeService(
|
||||
overrides: Partial<WorkspaceRuntimeService> = {},
|
||||
): WorkspaceRuntimeService {
|
||||
return {
|
||||
id: overrides.id ?? "service-storybook",
|
||||
companyId: overrides.companyId ?? "company-storybook",
|
||||
projectId: overrides.projectId ?? "project-board-ui",
|
||||
projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-board-ui",
|
||||
executionWorkspaceId: overrides.executionWorkspaceId ?? "execution-workspace-storybook",
|
||||
issueId: overrides.issueId ?? "issue-storybook-1",
|
||||
scopeType: overrides.scopeType ?? "execution_workspace",
|
||||
scopeId: overrides.scopeId ?? "execution-workspace-storybook",
|
||||
serviceName: overrides.serviceName ?? "storybook",
|
||||
status: overrides.status ?? "running",
|
||||
lifecycle: overrides.lifecycle ?? "ephemeral",
|
||||
reuseKey: overrides.reuseKey ?? "storybook",
|
||||
command: overrides.command ?? "pnpm storybook",
|
||||
cwd: overrides.cwd ?? "/Users/dotta/paperclip/ui",
|
||||
port: overrides.port ?? 6006,
|
||||
url: overrides.url ?? "http://localhost:6006",
|
||||
provider: overrides.provider ?? "local_process",
|
||||
providerRef: overrides.providerRef ?? null,
|
||||
ownerAgentId: overrides.ownerAgentId ?? "agent-codex",
|
||||
startedByRunId: overrides.startedByRunId ?? "run-storybook",
|
||||
lastUsedAt: overrides.lastUsedAt ?? recent(4),
|
||||
startedAt: overrides.startedAt ?? recent(16),
|
||||
stoppedAt: overrides.stoppedAt ?? null,
|
||||
stopPolicy: overrides.stopPolicy ?? null,
|
||||
healthStatus: overrides.healthStatus ?? "healthy",
|
||||
configIndex: overrides.configIndex ?? 0,
|
||||
createdAt: overrides.createdAt ?? recent(16),
|
||||
updatedAt: overrides.updatedAt ?? recent(4),
|
||||
};
|
||||
}
|
||||
|
||||
const storybookWorkspaceRuntime = {
|
||||
commands: [
|
||||
{
|
||||
id: "storybook",
|
||||
name: "Storybook",
|
||||
kind: "service",
|
||||
command: "pnpm storybook",
|
||||
cwd: "ui",
|
||||
lifecycle: "shared",
|
||||
},
|
||||
{
|
||||
id: "typecheck-ui",
|
||||
name: "UI typecheck",
|
||||
kind: "job",
|
||||
command: "pnpm --filter @paperclipai/ui typecheck",
|
||||
cwd: ".",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const storybookProjectWorkspaces: Project["workspaces"] = [
|
||||
{
|
||||
id: "workspace-board-ui",
|
||||
companyId: "company-storybook",
|
||||
@@ -237,43 +369,114 @@ export const storybookProjectWorkspaces = [
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: "board-ui",
|
||||
metadata: null,
|
||||
runtimeConfig: null,
|
||||
runtimeConfig: {
|
||||
workspaceRuntime: storybookWorkspaceRuntime,
|
||||
desiredState: "running" as const,
|
||||
serviceStates: { storybook: "running" as const },
|
||||
},
|
||||
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),
|
||||
},
|
||||
],
|
||||
runtimeServices: [createRuntimeService()],
|
||||
createdAt: recent(20_000),
|
||||
updatedAt: recent(4),
|
||||
},
|
||||
{
|
||||
id: "workspace-docs-remote",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
name: "Docs preview sandbox",
|
||||
sourceType: "remote_managed",
|
||||
cwd: null,
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
repoRef: "preview/docs-workspaces",
|
||||
defaultRef: "master",
|
||||
visibility: "advanced",
|
||||
setupCommand: "pnpm install",
|
||||
cleanupCommand: "pnpm dev:stop",
|
||||
remoteProvider: "vercel_sandbox",
|
||||
remoteWorkspaceRef: "vcsb_storybook_docs_0420",
|
||||
sharedWorkspaceKey: "docs-preview",
|
||||
metadata: { region: "iad1", ttlHours: 8 },
|
||||
runtimeConfig: {
|
||||
workspaceRuntime: {
|
||||
commands: [
|
||||
{
|
||||
id: "docs",
|
||||
name: "Docs preview",
|
||||
kind: "service",
|
||||
command: "pnpm docs:dev",
|
||||
cwd: ".",
|
||||
lifecycle: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
desiredState: "stopped",
|
||||
serviceStates: { docs: "stopped" },
|
||||
},
|
||||
isPrimary: false,
|
||||
runtimeServices: [
|
||||
createRuntimeService({
|
||||
id: "service-docs-preview",
|
||||
projectWorkspaceId: "workspace-docs-remote",
|
||||
executionWorkspaceId: null,
|
||||
issueId: "issue-storybook-6",
|
||||
scopeType: "project_workspace",
|
||||
scopeId: "workspace-docs-remote",
|
||||
serviceName: "docs",
|
||||
status: "stopped",
|
||||
lifecycle: "ephemeral",
|
||||
reuseKey: "docs-preview",
|
||||
command: "pnpm docs:dev",
|
||||
cwd: null,
|
||||
port: 4173,
|
||||
url: "https://paperclip-docs-preview.vercel.app",
|
||||
healthStatus: "unknown",
|
||||
lastUsedAt: recent(48),
|
||||
startedAt: recent(72),
|
||||
stoppedAt: recent(46),
|
||||
createdAt: recent(72),
|
||||
updatedAt: recent(46),
|
||||
}),
|
||||
],
|
||||
createdAt: recent(2_000),
|
||||
updatedAt: recent(46),
|
||||
},
|
||||
{
|
||||
id: "workspace-release-local",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
name: "Release smoke local checkout",
|
||||
sourceType: "local_path",
|
||||
cwd: "/Users/dotta/paperclip/.paperclip/workspaces/release-smoke",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
repoRef: "release/smoke-2026-04-20",
|
||||
defaultRef: "master",
|
||||
visibility: "advanced",
|
||||
setupCommand: "pnpm install",
|
||||
cleanupCommand: "pnpm dev:stop",
|
||||
remoteProvider: null,
|
||||
remoteWorkspaceRef: null,
|
||||
sharedWorkspaceKey: "release-smoke",
|
||||
metadata: null,
|
||||
runtimeConfig: {
|
||||
workspaceRuntime: {
|
||||
commands: [
|
||||
{
|
||||
id: "release-smoke",
|
||||
name: "Release smoke",
|
||||
kind: "job",
|
||||
command: "pnpm test:release-smoke",
|
||||
cwd: ".",
|
||||
},
|
||||
],
|
||||
},
|
||||
desiredState: "stopped",
|
||||
serviceStates: null,
|
||||
},
|
||||
isPrimary: false,
|
||||
runtimeServices: [],
|
||||
createdAt: recent(7_200),
|
||||
updatedAt: recent(90),
|
||||
},
|
||||
];
|
||||
|
||||
export const storybookExecutionWorkspaces: ExecutionWorkspace[] = [
|
||||
@@ -305,6 +508,57 @@ export const storybookExecutionWorkspaces: ExecutionWorkspace[] = [
|
||||
createdAt: recent(90),
|
||||
updatedAt: recent(3),
|
||||
},
|
||||
{
|
||||
id: "execution-workspace-cleanup",
|
||||
companyId: "company-storybook",
|
||||
projectId: "project-board-ui",
|
||||
projectWorkspaceId: "workspace-release-local",
|
||||
sourceIssueId: "issue-storybook-8",
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "PAP-1608 release smoke cleanup",
|
||||
status: "cleanup_failed",
|
||||
cwd: "/Users/dotta/paperclip/.paperclip/worktrees/PAP-1608-release-smoke-cleanup",
|
||||
repoUrl: "https://github.com/paperclipai/paperclip",
|
||||
baseRef: "master",
|
||||
branchName: "PAP-1608-release-smoke-cleanup",
|
||||
providerType: "git_worktree",
|
||||
providerRef: null,
|
||||
derivedFromExecutionWorkspaceId: null,
|
||||
lastUsedAt: recent(120),
|
||||
openedAt: recent(260),
|
||||
closedAt: recent(118),
|
||||
cleanupEligibleAt: recent(115),
|
||||
cleanupReason: "release smoke finished but the local worktree still has generated artifacts",
|
||||
config: {
|
||||
provisionCommand: null,
|
||||
teardownCommand: "pnpm dev:stop && rm -rf ui/storybook-static",
|
||||
cleanupCommand: null,
|
||||
workspaceRuntime: storybookWorkspaceRuntime,
|
||||
desiredState: "stopped",
|
||||
serviceStates: { storybook: "stopped" },
|
||||
},
|
||||
metadata: { dirtyEntryCount: 3 },
|
||||
runtimeServices: [
|
||||
createRuntimeService({
|
||||
id: "service-cleanup-storybook",
|
||||
projectWorkspaceId: "workspace-release-local",
|
||||
executionWorkspaceId: "execution-workspace-cleanup",
|
||||
issueId: "issue-storybook-8",
|
||||
scopeId: "execution-workspace-cleanup",
|
||||
status: "failed",
|
||||
healthStatus: "unhealthy",
|
||||
url: "http://localhost:6007",
|
||||
port: 6007,
|
||||
lastUsedAt: recent(130),
|
||||
startedAt: recent(210),
|
||||
stoppedAt: recent(125),
|
||||
updatedAt: recent(120),
|
||||
}),
|
||||
],
|
||||
createdAt: recent(260),
|
||||
updatedAt: recent(120),
|
||||
},
|
||||
];
|
||||
|
||||
function createProject(overrides: Partial<Project> = {}): Project {
|
||||
@@ -314,8 +568,10 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
||||
companyId: "company-storybook",
|
||||
urlKey: "board-ui",
|
||||
goalId: "goal-company",
|
||||
goalIds: ["goal-company"],
|
||||
goals: [{ id: "goal-company", title: "Build Paperclip" }],
|
||||
goalIds: ["goal-company", "goal-board-ux"],
|
||||
goals: storybookGoals
|
||||
.filter((goal) => goal.id === "goal-company" || goal.id === "goal-board-ux")
|
||||
.map(({ id, title }) => ({ id, title })),
|
||||
name: "Board UI",
|
||||
description: "Navigation, command, and operator layout polish.",
|
||||
status: "in_progress",
|
||||
@@ -388,6 +644,45 @@ export const storybookProjects: Project[] = [
|
||||
pausedAt: recent(90),
|
||||
updatedAt: recent(90),
|
||||
}),
|
||||
createProject({
|
||||
id: "project-archived-import",
|
||||
urlKey: "archived-import-wizard",
|
||||
name: "Archived Import Wizard",
|
||||
description: "Preserved for audit after the import workflow moved into company packages.",
|
||||
status: "cancelled",
|
||||
leadAgentId: null,
|
||||
goalId: "goal-archived-import",
|
||||
goalIds: ["goal-archived-import"],
|
||||
goals: [{ id: "goal-archived-import", title: "Retire old import wizard" }],
|
||||
color: "#64748b",
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "",
|
||||
effectiveLocalFolder: "Not configured",
|
||||
origin: "local_folder",
|
||||
},
|
||||
executionWorkspacePolicy: {
|
||||
enabled: false,
|
||||
defaultMode: "shared_workspace",
|
||||
allowIssueOverride: false,
|
||||
defaultProjectWorkspaceId: null,
|
||||
workspaceStrategy: null,
|
||||
workspaceRuntime: null,
|
||||
branchPolicy: null,
|
||||
pullRequestPolicy: null,
|
||||
runtimePolicy: null,
|
||||
cleanupPolicy: null,
|
||||
},
|
||||
archivedAt: recent(2_400),
|
||||
updatedAt: recent(2_400),
|
||||
}),
|
||||
];
|
||||
|
||||
export function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
@@ -572,6 +867,27 @@ export const storybookIssues: Issue[] = [
|
||||
lastActivityAt: recent(85),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-storybook-8",
|
||||
title: "Clean up release smoke worktree",
|
||||
description: "Close the isolated release smoke workspace after static preview review.",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-codex",
|
||||
checkoutRunId: null,
|
||||
executionRunId: "run-release-smoke-cleanup",
|
||||
executionLockedAt: null,
|
||||
startedAt: recent(260),
|
||||
identifier: "PAP-1608",
|
||||
issueNumber: 1608,
|
||||
projectId: "project-board-ui",
|
||||
projectWorkspaceId: "workspace-release-local",
|
||||
executionWorkspaceId: "execution-workspace-cleanup",
|
||||
labelIds: ["label-ui", "label-risk"],
|
||||
labels: labelsFor(["label-ui", "label-risk"]),
|
||||
lastActivityAt: recent(120),
|
||||
isUnreadForMe: false,
|
||||
}),
|
||||
];
|
||||
|
||||
export const storybookIssueDocuments: IssueDocument[] = [
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Agent, FeedbackVote, IssueComment } from "@paperclipai/shared";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import type { LiveRunForIssue } from "@/api/heartbeats";
|
||||
import { CommentThread } from "@/components/CommentThread";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { RunChatSurface } from "@/components/RunChatSurface";
|
||||
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
IssueChatTranscriptEntry,
|
||||
} from "@/lib/issue-chat-messages";
|
||||
import type { IssueTimelineEvent } from "@/lib/issue-timeline-events";
|
||||
import { storybookAgentMap, storybookAgents } from "../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const projectId = "project-board-ui";
|
||||
const issueId = "issue-chat-comments";
|
||||
const currentUserId = "user-board";
|
||||
|
||||
type StoryComment = IssueComment & {
|
||||
runId?: string | null;
|
||||
runAgentId?: string | null;
|
||||
clientId?: string;
|
||||
clientStatus?: "pending" | "queued";
|
||||
queueState?: "queued";
|
||||
queueTargetRunId?: string | null;
|
||||
};
|
||||
|
||||
const codexAgent = storybookAgents.find((agent) => agent.id === "agent-codex") ?? storybookAgents[0]!;
|
||||
const qaAgent = storybookAgents.find((agent) => agent.id === "agent-qa") ?? storybookAgents[1]!;
|
||||
const ctoAgent = storybookAgents.find((agent) => agent.id === "agent-cto") ?? storybookAgents[2]!;
|
||||
|
||||
const boardUserLabels = new Map<string, string>([
|
||||
["user-board", "Riley Board"],
|
||||
["user-product", "Mara Product"],
|
||||
]);
|
||||
|
||||
function Section({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="paperclip-story__frame overflow-hidden">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<div className="paperclip-story__label">{eyebrow}</div>
|
||||
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ScenarioCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<StoryComment>): StoryComment {
|
||||
const createdAt = overrides.createdAt ?? new Date("2026-04-20T14:00:00.000Z");
|
||||
return {
|
||||
id: "comment-default",
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: currentUserId,
|
||||
body: "",
|
||||
createdAt,
|
||||
updatedAt: overrides.updatedAt ?? createdAt,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSystemEvent(overrides: Partial<IssueTimelineEvent>): IssueTimelineEvent {
|
||||
return {
|
||||
id: "event-default",
|
||||
createdAt: new Date("2026-04-20T14:00:00.000Z"),
|
||||
actorType: "system",
|
||||
actorId: "paperclip",
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mentionOptions: MentionOption[] = [
|
||||
{
|
||||
id: `agent:${codexAgent.id}`,
|
||||
name: codexAgent.name,
|
||||
kind: "agent",
|
||||
agentId: codexAgent.id,
|
||||
agentIcon: codexAgent.icon,
|
||||
},
|
||||
{
|
||||
id: `agent:${qaAgent.id}`,
|
||||
name: qaAgent.name,
|
||||
kind: "agent",
|
||||
agentId: qaAgent.id,
|
||||
agentIcon: qaAgent.icon,
|
||||
},
|
||||
{
|
||||
id: `project:${projectId}`,
|
||||
name: "Board UI",
|
||||
kind: "project",
|
||||
projectId,
|
||||
projectColor: "#0f766e",
|
||||
},
|
||||
];
|
||||
|
||||
const reassignOptions: InlineEntityOption[] = [
|
||||
{
|
||||
id: `agent:${codexAgent.id}`,
|
||||
label: codexAgent.name,
|
||||
searchText: `${codexAgent.name} engineer codex`,
|
||||
},
|
||||
{
|
||||
id: `agent:${qaAgent.id}`,
|
||||
label: qaAgent.name,
|
||||
searchText: `${qaAgent.name} qa browser review`,
|
||||
},
|
||||
{
|
||||
id: `agent:${ctoAgent.id}`,
|
||||
label: ctoAgent.name,
|
||||
searchText: `${ctoAgent.name} architecture review`,
|
||||
},
|
||||
{
|
||||
id: `user:${currentUserId}`,
|
||||
label: "Riley Board",
|
||||
searchText: "board operator",
|
||||
},
|
||||
];
|
||||
|
||||
const singleComment = [
|
||||
createComment({
|
||||
id: "comment-single-board",
|
||||
body: "Please make the issue chat states reviewable in Storybook before the next UI pass.",
|
||||
createdAt: new Date("2026-04-20T13:12:00.000Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
const longThreadComments = [
|
||||
createComment({
|
||||
id: "comment-long-board",
|
||||
body: "The chat surface should show the operator request first, then agent progress, then review follow-up. Keep the density close to the issue page.",
|
||||
createdAt: new Date("2026-04-20T13:02:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-long-agent",
|
||||
authorAgentId: codexAgent.id,
|
||||
authorUserId: null,
|
||||
body: "I found the existing `IssueChatThread` and `RunChatSurface` components and am building the stories around those props.",
|
||||
createdAt: new Date("2026-04-20T13:08:00.000Z"),
|
||||
runId: "run-comment-thread-01",
|
||||
runAgentId: codexAgent.id,
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-long-product",
|
||||
authorUserId: "user-product",
|
||||
body: "Also include the old comment timeline so we can compare it with the assistant-style issue chat.",
|
||||
createdAt: new Date("2026-04-20T13:16:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-long-qa",
|
||||
authorAgentId: qaAgent.id,
|
||||
authorUserId: null,
|
||||
body: "QA note: the thread should stay readable with long markdown and when a queued operator reply is visible.",
|
||||
createdAt: new Date("2026-04-20T13:24:00.000Z"),
|
||||
runId: "run-comment-thread-02",
|
||||
runAgentId: qaAgent.id,
|
||||
}),
|
||||
];
|
||||
|
||||
const markdownComments = [
|
||||
createComment({
|
||||
id: "comment-markdown-board",
|
||||
body: [
|
||||
"Acceptance criteria:",
|
||||
"",
|
||||
"- Cover empty, single, and long comment states",
|
||||
"- Show a code block in a comment",
|
||||
"- Include a link to [the issue guide](/issues/PAP-1676)",
|
||||
"",
|
||||
"```ts",
|
||||
"const success = stories.some((story) => story.includes(\"IssueChatThread\"));",
|
||||
"```",
|
||||
].join("\n"),
|
||||
createdAt: new Date("2026-04-20T13:28:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-mentions-agent",
|
||||
authorAgentId: codexAgent.id,
|
||||
authorUserId: null,
|
||||
body: "@QAChecker I added the fixture coverage. Please focus browser review on links, code blocks, and the queued comment treatment.",
|
||||
createdAt: new Date("2026-04-20T13:35:00.000Z"),
|
||||
runId: "run-markdown-01",
|
||||
runAgentId: codexAgent.id,
|
||||
}),
|
||||
];
|
||||
|
||||
const queuedComment = createComment({
|
||||
id: "comment-queued-board",
|
||||
body: "@CodexCoder after this run finishes, add a compact embedded variant too.",
|
||||
createdAt: new Date("2026-04-20T13:39:00.000Z"),
|
||||
clientId: "client-queued-storybook",
|
||||
clientStatus: "queued",
|
||||
queueState: "queued",
|
||||
queueTargetRunId: "run-live-chat-01",
|
||||
});
|
||||
|
||||
const commentTimelineEvents: IssueTimelineEvent[] = [
|
||||
createSystemEvent({
|
||||
id: "event-system-checkout",
|
||||
createdAt: new Date("2026-04-20T13:04:00.000Z"),
|
||||
actorType: "system",
|
||||
actorId: "paperclip",
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
}),
|
||||
createSystemEvent({
|
||||
id: "event-board-reassign",
|
||||
createdAt: new Date("2026-04-20T13:18:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: currentUserId,
|
||||
assigneeChange: {
|
||||
from: { agentId: codexAgent.id, userId: null },
|
||||
to: { agentId: qaAgent.id, userId: null },
|
||||
},
|
||||
statusChange: undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const commentLinkedRuns = [
|
||||
{
|
||||
runId: "run-comment-thread-01",
|
||||
status: "succeeded",
|
||||
agentId: codexAgent.id,
|
||||
createdAt: new Date("2026-04-20T13:07:00.000Z"),
|
||||
startedAt: new Date("2026-04-20T13:07:00.000Z"),
|
||||
finishedAt: new Date("2026-04-20T13:11:00.000Z"),
|
||||
},
|
||||
{
|
||||
runId: "run-comment-thread-02",
|
||||
status: "running",
|
||||
agentId: qaAgent.id,
|
||||
createdAt: new Date("2026-04-20T13:22:00.000Z"),
|
||||
startedAt: new Date("2026-04-20T13:22:00.000Z"),
|
||||
finishedAt: null,
|
||||
},
|
||||
];
|
||||
|
||||
const feedbackVotes: FeedbackVote[] = [
|
||||
{
|
||||
id: "feedback-chat-comment-01",
|
||||
companyId,
|
||||
issueId,
|
||||
targetType: "issue_comment",
|
||||
targetId: "comment-issue-agent",
|
||||
authorUserId: currentUserId,
|
||||
vote: "up",
|
||||
reason: null,
|
||||
sharedWithLabs: false,
|
||||
sharedAt: null,
|
||||
consentVersion: null,
|
||||
redactionSummary: null,
|
||||
createdAt: new Date("2026-04-20T13:52:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T13:52:00.000Z"),
|
||||
},
|
||||
];
|
||||
|
||||
const liveRun: LiveRunForIssue = {
|
||||
id: "run-live-chat-01",
|
||||
status: "running",
|
||||
invocationSource: "manual",
|
||||
triggerDetail: "comment",
|
||||
createdAt: "2026-04-20T13:40:00.000Z",
|
||||
startedAt: "2026-04-20T13:40:02.000Z",
|
||||
finishedAt: null,
|
||||
agentId: codexAgent.id,
|
||||
agentName: codexAgent.name,
|
||||
adapterType: "codex_local",
|
||||
issueId,
|
||||
};
|
||||
|
||||
const liveRunTranscript: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-20T13:40:08.000Z",
|
||||
text: "I am wiring the chat and comments Storybook coverage now.",
|
||||
},
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-20T13:40:12.000Z",
|
||||
text: "Need fixtures that exercise MarkdownBody, assistant-ui messages, and the embedded run transcript path without reaching the API.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-20T13:40:18.000Z",
|
||||
name: "rg",
|
||||
toolUseId: "tool-live-rg",
|
||||
input: {
|
||||
query: "IssueChatThread",
|
||||
cwd: "ui/src",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-20T13:40:20.000Z",
|
||||
toolUseId: "tool-live-rg",
|
||||
content: "ui/src/components/IssueChatThread.tsx\nui/src/components/RunChatSurface.tsx",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-20T13:40:31.000Z",
|
||||
text: [
|
||||
"The live run should render code blocks as part of the assistant response:",
|
||||
"",
|
||||
"```tsx",
|
||||
"<RunChatSurface run={run} transcript={entries} hasOutput />",
|
||||
"```",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-20T13:40:44.000Z",
|
||||
name: "apply_patch",
|
||||
toolUseId: "tool-live-patch",
|
||||
input: {
|
||||
file: "ui/storybook/stories/chat-comments.stories.tsx",
|
||||
action: "add fixtures",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-20T13:40:49.000Z",
|
||||
toolUseId: "tool-live-patch",
|
||||
content: "Added Storybook scenarios for comment thread, run chat, and issue chat.",
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
|
||||
const issueChatComments: IssueChatComment[] = [
|
||||
createComment({
|
||||
id: "comment-issue-board",
|
||||
body: "Please turn the comment thread into a reviewable chat surface. I need to see operator messages, agent output, system events, and live run progress together.",
|
||||
createdAt: new Date("2026-04-20T13:44:00.000Z"),
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-issue-agent",
|
||||
authorAgentId: codexAgent.id,
|
||||
authorUserId: null,
|
||||
body: "I kept the existing component contracts and added fixtures with realistic Paperclip work: checkout, comments, linked runs, and review feedback.",
|
||||
createdAt: new Date("2026-04-20T13:50:00.000Z"),
|
||||
runId: "run-issue-chat-01",
|
||||
runAgentId: codexAgent.id,
|
||||
}),
|
||||
createComment({
|
||||
id: "comment-issue-queued",
|
||||
body: "@QAChecker please do a quick visual pass after the Storybook build is green.",
|
||||
createdAt: new Date("2026-04-20T13:56:00.000Z"),
|
||||
clientId: "client-issue-queued",
|
||||
clientStatus: "queued",
|
||||
queueState: "queued",
|
||||
queueTargetRunId: liveRun.id,
|
||||
}),
|
||||
];
|
||||
|
||||
const issueTimelineEvents: IssueTimelineEvent[] = [
|
||||
createSystemEvent({
|
||||
id: "event-issue-checkout",
|
||||
createdAt: new Date("2026-04-20T13:42:00.000Z"),
|
||||
actorType: "system",
|
||||
actorId: "paperclip",
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
}),
|
||||
createSystemEvent({
|
||||
id: "event-issue-assignee",
|
||||
createdAt: new Date("2026-04-20T13:43:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: currentUserId,
|
||||
statusChange: undefined,
|
||||
assigneeChange: {
|
||||
from: { agentId: null, userId: null },
|
||||
to: { agentId: codexAgent.id, userId: null },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const issueLinkedRuns: IssueChatLinkedRun[] = [
|
||||
{
|
||||
runId: "run-issue-chat-01",
|
||||
status: "succeeded",
|
||||
agentId: codexAgent.id,
|
||||
agentName: codexAgent.name,
|
||||
adapterType: "codex_local",
|
||||
createdAt: new Date("2026-04-20T13:46:00.000Z"),
|
||||
startedAt: new Date("2026-04-20T13:46:00.000Z"),
|
||||
finishedAt: new Date("2026-04-20T13:51:00.000Z"),
|
||||
hasStoredOutput: true,
|
||||
},
|
||||
];
|
||||
|
||||
const issueTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
|
||||
[
|
||||
"run-issue-chat-01",
|
||||
[
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-04-20T13:46:10.000Z",
|
||||
text: "Checking the existing Storybook organization before adding a new product group.",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: "2026-04-20T13:46:16.000Z",
|
||||
name: "read_file",
|
||||
toolUseId: "tool-issue-read",
|
||||
input: {
|
||||
path: "ui/storybook/stories/overview.stories.tsx",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: "2026-04-20T13:46:19.000Z",
|
||||
toolUseId: "tool-issue-read",
|
||||
content: "The coverage map already lists Chat & comments as a planned section.",
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-04-20T13:49:00.000Z",
|
||||
text: "Added the story file and kept every fixture local to the story so product data fixtures stay stable.",
|
||||
},
|
||||
{
|
||||
kind: "diff",
|
||||
ts: "2026-04-20T13:49:04.000Z",
|
||||
changeType: "file_header",
|
||||
text: "diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx",
|
||||
},
|
||||
{
|
||||
kind: "diff",
|
||||
ts: "2026-04-20T13:49:05.000Z",
|
||||
changeType: "add",
|
||||
text: "+export const FullSurfaceMatrix: Story = {};",
|
||||
},
|
||||
],
|
||||
],
|
||||
[liveRun.id, liveRunTranscript],
|
||||
]);
|
||||
|
||||
function ThreadProps({
|
||||
comments,
|
||||
queuedComments = [],
|
||||
timelineEvents = [],
|
||||
}: {
|
||||
comments: StoryComment[];
|
||||
queuedComments?: StoryComment[];
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
}) {
|
||||
return (
|
||||
<CommentThread
|
||||
comments={comments}
|
||||
queuedComments={queuedComments}
|
||||
linkedRuns={commentLinkedRuns}
|
||||
timelineEvents={timelineEvents}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus="in_progress"
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
onAdd={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={`agent:${codexAgent.id}`}
|
||||
suggestedAssigneeValue={`agent:${codexAgent.id}`}
|
||||
mentions={mentionOptions}
|
||||
onInterruptQueued={async () => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentThreadMatrix() {
|
||||
return (
|
||||
<Section eyebrow="CommentThread" title="Timeline comments across empty, single, long, markdown, and queued states">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<ScenarioCard title="Empty thread" description="No timeline entries yet, with the composer ready for the first comment.">
|
||||
<ThreadProps comments={[]} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Single board comment" description="A minimal operator request with timestamp and composer controls.">
|
||||
<ThreadProps comments={singleComment} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Long mixed-author thread" description="Board, product, agent, linked run, and system timeline entries in one stack.">
|
||||
<ThreadProps comments={longThreadComments} timelineEvents={commentTimelineEvents} />
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Markdown, code, mentions, and links" description="Markdown rendering with code fences, @mentions, links, and a queued reply.">
|
||||
<ThreadProps comments={markdownComments} queuedComments={[queuedComment]} />
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RunChatMatrix() {
|
||||
return (
|
||||
<Section eyebrow="RunChatSurface" title="Live run chat with streaming output, tools, and code blocks">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<RunChatSurface
|
||||
run={liveRun}
|
||||
transcript={liveRunTranscript}
|
||||
hasOutput
|
||||
companyId={companyId}
|
||||
/>
|
||||
</div>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Run fixture shape</CardTitle>
|
||||
<CardDescription>Streaming transcript entries mixed into the same chat renderer used by issue chat.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant="secondary">running</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Tool calls</span>
|
||||
<span className="font-mono text-xs">rg, apply_patch</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Transcript entries</span>
|
||||
<span className="font-mono text-xs">{liveRunTranscript.length}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueChatMatrix() {
|
||||
return (
|
||||
<Section eyebrow="IssueChatThread" title="Issue-specific chat with timeline events, linked runs, and live output">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueChatThread
|
||||
comments={issueChatComments}
|
||||
linkedRuns={issueLinkedRuns}
|
||||
timelineEvents={issueTimelineEvents}
|
||||
liveRuns={[liveRun]}
|
||||
feedbackVotes={feedbackVotes}
|
||||
feedbackDataSharingPreference="allowed"
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus="in_progress"
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={boardUserLabels}
|
||||
onAdd={async () => {}}
|
||||
onVote={async () => {}}
|
||||
onStopRun={async () => {}}
|
||||
enableReassign
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={`agent:${codexAgent.id}`}
|
||||
suggestedAssigneeValue={`agent:${codexAgent.id}`}
|
||||
mentions={mentionOptions}
|
||||
enableLiveTranscriptPolling={false}
|
||||
transcriptsByRunId={issueTranscriptsByRunId}
|
||||
hasOutputForRun={(runId) => issueTranscriptsByRunId.has(runId)}
|
||||
includeSucceededRunsWithoutOutput
|
||||
onInterruptQueued={async () => {}}
|
||||
onCancelQueued={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<ScenarioCard title="Empty issue chat" description="The standalone empty state before an operator or agent posts.">
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
timelineEvents={[]}
|
||||
linkedRuns={[]}
|
||||
liveRuns={[]}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
emptyMessage="No chat yet. The first operator note will start the issue conversation."
|
||||
/>
|
||||
</ScenarioCard>
|
||||
<ScenarioCard title="Disabled composer" description="Review state where the conversation remains readable but input is paused.">
|
||||
<IssueChatThread
|
||||
comments={singleComment}
|
||||
timelineEvents={[]}
|
||||
linkedRuns={[]}
|
||||
liveRuns={[]}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
agentMap={storybookAgentMap}
|
||||
currentUserId={currentUserId}
|
||||
onAdd={async () => {}}
|
||||
showJumpToLatest={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
composerDisabledReason="This issue is in review. Request changes or approve it from the review controls."
|
||||
/>
|
||||
</ScenarioCard>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatCommentsStories() {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">
|
||||
<section className="paperclip-story__frame p-6">
|
||||
<div className="paperclip-story__label">Chat & Comments</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Threaded work conversations</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Fixture-backed coverage for classic issue comments, embedded run chat, and the assistant-style issue chat
|
||||
surface. The scenarios use Paperclip operational content with mixed authors, system timeline events,
|
||||
markdown, code blocks, @mentions, links, queued comments, tool calls, and streaming run output.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<CommentThreadMatrix />
|
||||
<RunChatMatrix />
|
||||
<IssueChatMatrix />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Chat & Comments",
|
||||
component: ChatCommentsStories,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Chat and comments stories exercise CommentThread, RunChatSurface, and IssueChatThread across empty, single, long, markdown, mention, timeline, queued, linked-run, and streaming transcript states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ChatCommentsStories>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const FullSurfaceMatrix: Story = {};
|
||||
|
||||
export const CommentThreads: Story = {
|
||||
render: () => (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<CommentThreadMatrix />
|
||||
</main>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const LiveRunChat: Story = {
|
||||
render: () => (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<RunChatMatrix />
|
||||
</main>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const IssueChatWithTimeline: Story = {
|
||||
render: () => (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner">
|
||||
<IssueChatMatrix />
|
||||
</main>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,753 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { HeartbeatRun, Issue } from "@paperclipai/shared";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Archive,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileCode2,
|
||||
FolderKanban,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ChartCard,
|
||||
IssueStatusChart,
|
||||
PriorityChart,
|
||||
RunActivityChart,
|
||||
SuccessRateChart,
|
||||
} from "@/components/ActivityCharts";
|
||||
import { AsciiArtAnimation } from "@/components/AsciiArtAnimation";
|
||||
import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { FilterBar, type FilterValue } from "@/components/FilterBar";
|
||||
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||
import { LiveRunWidget } from "@/components/LiveRunWidget";
|
||||
import { OnboardingWizard } from "@/components/OnboardingWizard";
|
||||
import {
|
||||
buildFileTree,
|
||||
collectAllPaths,
|
||||
countFiles,
|
||||
PackageFileTree,
|
||||
parseFrontmatter,
|
||||
type FileTreeNode,
|
||||
} from "@/components/PackageFileTree";
|
||||
import { PageSkeleton } from "@/components/PageSkeleton";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { SwipeToArchive } from "@/components/SwipeToArchive";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useDialog } from "@/context/DialogContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
createIssue,
|
||||
storybookAgents,
|
||||
storybookIssues,
|
||||
storybookLiveRuns,
|
||||
} from "../fixtures/paperclipData";
|
||||
|
||||
const companyId = "company-storybook";
|
||||
const primaryIssueId = "issue-storybook-1";
|
||||
|
||||
function StoryShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="paperclip-story">
|
||||
<main className="paperclip-story__inner space-y-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
children: React.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 daysAgo(days: number, hour = 12): Date {
|
||||
const date = new Date();
|
||||
date.setHours(hour, 0, 0, 0);
|
||||
date.setDate(date.getDate() - days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function makeHeartbeatRun(overrides: Partial<HeartbeatRun>): HeartbeatRun {
|
||||
const createdAt = overrides.createdAt ?? daysAgo(1);
|
||||
const run: HeartbeatRun = {
|
||||
id: "run-fixture",
|
||||
companyId,
|
||||
agentId: "agent-codex",
|
||||
invocationSource: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
status: "succeeded",
|
||||
startedAt: createdAt,
|
||||
finishedAt: new Date(createdAt.getTime() + 11 * 60_000),
|
||||
error: null,
|
||||
wakeupRequestId: null,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
sessionIdBefore: null,
|
||||
sessionIdAfter: null,
|
||||
logStore: null,
|
||||
logRef: null,
|
||||
logBytes: 0,
|
||||
logSha256: null,
|
||||
logCompressed: false,
|
||||
stdoutExcerpt: null,
|
||||
stderrExcerpt: null,
|
||||
errorCode: null,
|
||||
externalRunId: null,
|
||||
processPid: null,
|
||||
processGroupId: null,
|
||||
processStartedAt: createdAt,
|
||||
retryOfRunId: null,
|
||||
processLossRetryCount: 0,
|
||||
livenessState: "completed",
|
||||
livenessReason: null,
|
||||
continuationAttempt: 0,
|
||||
lastUsefulActionAt: null,
|
||||
nextAction: null,
|
||||
contextSnapshot: null,
|
||||
updatedAt: createdAt,
|
||||
...overrides,
|
||||
createdAt,
|
||||
updatedAt: overrides.updatedAt ?? createdAt,
|
||||
};
|
||||
return run;
|
||||
}
|
||||
|
||||
const activityRuns: HeartbeatRun[] = [
|
||||
makeHeartbeatRun({ id: "run-chart-1", status: "succeeded", createdAt: daysAgo(13), startedAt: daysAgo(13) }),
|
||||
makeHeartbeatRun({ id: "run-chart-2", status: "succeeded", createdAt: daysAgo(10), startedAt: daysAgo(10) }),
|
||||
makeHeartbeatRun({ id: "run-chart-3", status: "failed", createdAt: daysAgo(10), startedAt: daysAgo(10, 15), exitCode: 1 }),
|
||||
makeHeartbeatRun({ id: "run-chart-4", status: "running", createdAt: daysAgo(7), startedAt: daysAgo(7), finishedAt: null }),
|
||||
makeHeartbeatRun({ id: "run-chart-5", status: "succeeded", createdAt: daysAgo(5), startedAt: daysAgo(5) }),
|
||||
makeHeartbeatRun({ id: "run-chart-6", status: "timed_out", createdAt: daysAgo(3), startedAt: daysAgo(3), errorCode: "timeout" }),
|
||||
makeHeartbeatRun({ id: "run-chart-7", status: "succeeded", createdAt: daysAgo(1), startedAt: daysAgo(1) }),
|
||||
makeHeartbeatRun({ id: "run-chart-8", status: "succeeded", createdAt: daysAgo(1, 16), startedAt: daysAgo(1, 16) }),
|
||||
];
|
||||
|
||||
const activityIssues = [
|
||||
{ priority: "high", status: "in_progress", createdAt: daysAgo(13) },
|
||||
{ priority: "critical", status: "blocked", createdAt: daysAgo(11) },
|
||||
{ priority: "medium", status: "todo", createdAt: daysAgo(9) },
|
||||
{ priority: "medium", status: "in_review", createdAt: daysAgo(9, 16) },
|
||||
{ priority: "low", status: "done", createdAt: daysAgo(6) },
|
||||
{ priority: "high", status: "todo", createdAt: daysAgo(4) },
|
||||
{ priority: "critical", status: "in_progress", createdAt: daysAgo(2) },
|
||||
{ priority: "medium", status: "done", createdAt: daysAgo(1) },
|
||||
];
|
||||
|
||||
const kanbanIssues: Issue[] = [
|
||||
...storybookIssues,
|
||||
createIssue({
|
||||
id: "issue-kanban-backlog",
|
||||
identifier: "PAP-1701",
|
||||
issueNumber: 1701,
|
||||
title: "Sketch company analytics dashboard",
|
||||
status: "backlog",
|
||||
priority: "low",
|
||||
assigneeAgentId: "agent-cto",
|
||||
}),
|
||||
createIssue({
|
||||
id: "issue-kanban-cancelled",
|
||||
identifier: "PAP-1702",
|
||||
issueNumber: 1702,
|
||||
title: "Remove obsolete color token migration",
|
||||
status: "cancelled",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const packageFiles: Record<string, string> = {
|
||||
"COMPANY.md": "---\nname: Paperclip Storybook\nkind: company\n---\nFixture company package for UI review.",
|
||||
"agents/codexcoder/AGENTS.md": "---\nname: CodexCoder\nskills:\n - frontend-design\n - paperclip\n---\nShips product UI and verifies changes.",
|
||||
"agents/qachecker/AGENTS.md": "---\nname: QAChecker\nskills:\n - web-design-guidelines\n---\nReviews browser behavior and acceptance criteria.",
|
||||
"projects/board-ui/PROJECT.md": "---\ntitle: Board UI\nstatus: in_progress\n---\nStorybook and operator control-plane surfaces.",
|
||||
"tasks/PAP-1641.md": "---\ntitle: Create super-detailed storybooks\npriority: high\n---\nParent issue for Storybook coverage.",
|
||||
"tasks/PAP-1677.md": "---\ntitle: Data Visualization & Misc stories\npriority: medium\n---\nFixture task for this story file.",
|
||||
"skills/frontend-design/SKILL.md": "---\nname: frontend-design\n---\nDesign quality guidance.",
|
||||
};
|
||||
|
||||
const actionMap = new Map([
|
||||
["COMPANY.md", "replace"],
|
||||
["agents/codexcoder/AGENTS.md", "update"],
|
||||
["agents/qachecker/AGENTS.md", "create"],
|
||||
["tasks/PAP-1677.md", "create"],
|
||||
]);
|
||||
|
||||
function ActivityChartsMatrix({ empty = false }: { empty?: boolean }) {
|
||||
const runs = empty ? [] : activityRuns;
|
||||
const issues = empty ? [] : activityIssues;
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="ActivityCharts" title={empty ? "Empty activity timelines" : "Two-week activity timelines"}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ChartCard title="Run activity" subtitle="Succeeded, failed, and in-flight heartbeats">
|
||||
<RunActivityChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success rate" subtitle="Daily completion ratio">
|
||||
<SuccessRateChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issue priority" subtitle="Created issues by urgency">
|
||||
<PriorityChart issues={issues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issue status" subtitle="Created issues by workflow state">
|
||||
<IssueStatusChart issues={issues} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanBoardDemo({ empty = false }: { empty?: boolean }) {
|
||||
const [issues, setIssues] = useState<Issue[]>(empty ? [] : kanbanIssues);
|
||||
const liveIssueIds = useMemo(() => new Set(["issue-storybook-1", "issue-kanban-backlog"]), []);
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="KanbanBoard" title={empty ? "Collapsed empty workflow columns" : "Draggable issue cards by status"}>
|
||||
<KanbanBoard
|
||||
issues={issues}
|
||||
agents={storybookAgents}
|
||||
liveIssueIds={liveIssueIds}
|
||||
onUpdateIssue={(id, data) => {
|
||||
setIssues((current) =>
|
||||
current.map((issue) => (issue.id === id ? { ...issue, ...data } : issue)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterBarDemo({ empty = false }: { empty?: boolean }) {
|
||||
const [filters, setFilters] = useState<FilterValue[]>(
|
||||
empty
|
||||
? []
|
||||
: [
|
||||
{ key: "status", label: "Status", value: "In progress" },
|
||||
{ key: "assignee", label: "Assignee", value: "CodexCoder" },
|
||||
{ key: "priority", label: "Priority", value: "High" },
|
||||
{ key: "project", label: "Project", value: "Board UI" },
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="FilterBar" title={empty ? "No active filters" : "Active removable filter chips"}>
|
||||
<div className="rounded-lg border border-dashed border-border bg-background/70 p-4">
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
onRemove={(key) => setFilters((current) => current.filter((filter) => filter.key !== key))}
|
||||
onClear={() => setFilters([])}
|
||||
/>
|
||||
{filters.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
No filters are active.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveRunWidgetStory({ empty = false, loading = false }: { empty?: boolean; loading?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
queryClient.setQueryData(queryKeys.issues.liveRuns(primaryIssueId), empty ? [] : storybookLiveRuns);
|
||||
queryClient.setQueryData(queryKeys.issues.activeRun(primaryIssueId), empty ? null : storybookLiveRuns[0]);
|
||||
}, [empty, loading, queryClient]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="LiveRunWidget" title="Loading live run status">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-background/70 p-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Waiting for the first run poll.
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="LiveRunWidget" title={empty ? "No active run" : "Streaming run indicator"}>
|
||||
<LiveRunWidget issueId={primaryIssueId} />
|
||||
{empty && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-background/70 p-4 text-sm text-muted-foreground">
|
||||
<Clock3 className="h-4 w-4" />
|
||||
The widget renders no panel when the issue has no live runs.
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function OpenOnboardingOnMount({ initialStep }: { initialStep: 1 | 2 }) {
|
||||
const { openOnboarding } = useDialog();
|
||||
|
||||
useEffect(() => {
|
||||
openOnboarding(initialStep === 1 ? { initialStep } : { initialStep, companyId });
|
||||
}, [initialStep, openOnboarding]);
|
||||
|
||||
return <OnboardingWizard />;
|
||||
}
|
||||
|
||||
function PackageFileTreeDemo({ empty = false }: { empty?: boolean }) {
|
||||
const nodes = useMemo(
|
||||
() => (empty ? [] : buildFileTree(packageFiles, actionMap)),
|
||||
[empty],
|
||||
);
|
||||
const allFilePaths = useMemo(() => collectAllPaths(nodes, "file"), [nodes]);
|
||||
const [expandedDirs, setExpandedDirs] = useState(() => collectAllPaths(nodes, "dir"));
|
||||
const [checkedFiles, setCheckedFiles] = useState(() => allFilePaths);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(empty ? null : "tasks/PAP-1677.md");
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDirs(collectAllPaths(nodes, "dir"));
|
||||
setCheckedFiles(allFilePaths);
|
||||
setSelectedFile(empty ? null : "tasks/PAP-1677.md");
|
||||
}, [allFilePaths, empty, nodes]);
|
||||
|
||||
const selectedContent = selectedFile ? packageFiles[selectedFile] ?? "" : "";
|
||||
const frontmatter = selectedContent ? parseFrontmatter(selectedContent) : null;
|
||||
|
||||
function toggleDir(path: string) {
|
||||
setExpandedDirs((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCheck(path: string, kind: "file" | "dir") {
|
||||
setCheckedFiles((current) => {
|
||||
const next = new Set(current);
|
||||
const paths =
|
||||
kind === "file"
|
||||
? [path]
|
||||
: [...collectAllPaths(findNode(nodes, path)?.children ?? [], "file")];
|
||||
const shouldCheck = paths.some((candidate) => !next.has(candidate));
|
||||
for (const candidate of paths) {
|
||||
if (shouldCheck) next.add(candidate);
|
||||
else next.delete(candidate);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="PackageFileTree" title={empty ? "Empty package export" : "Selectable company package tree"}>
|
||||
{empty ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-background/70 p-6 text-sm text-muted-foreground">
|
||||
No files are included in this package preview.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(280px,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background/70">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3 text-sm">
|
||||
<span className="font-medium">Package contents</span>
|
||||
<Badge variant="outline">{countFiles(nodes)} files</Badge>
|
||||
</div>
|
||||
<PackageFileTree
|
||||
nodes={nodes}
|
||||
selectedFile={selectedFile}
|
||||
expandedDirs={expandedDirs}
|
||||
checkedFiles={checkedFiles}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={setSelectedFile}
|
||||
onToggleCheck={toggleCheck}
|
||||
renderFileExtra={(node) =>
|
||||
node.action ? (
|
||||
<Badge variant="secondary" className="ml-auto text-[10px]">
|
||||
{node.action}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Card className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileCode2 className="h-4 w-4" />
|
||||
{selectedFile}
|
||||
</CardTitle>
|
||||
<CardDescription>Frontmatter and markdown body parsed from the selected package file.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{frontmatter ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{Object.entries(frontmatter.data).map(([key, value]) => (
|
||||
<div key={key} className="rounded-md border border-border bg-background/70 p-2">
|
||||
<div className="text-[10px] uppercase text-muted-foreground">{key}</div>
|
||||
<div className="mt-1 text-sm">{Array.isArray(value) ? value.join(", ") : value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<pre className="max-h-56 overflow-auto rounded-md bg-muted/40 p-3 text-xs leading-5">
|
||||
{frontmatter?.body.trim() || selectedContent}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | null {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node;
|
||||
const child = findNode(node.children, path);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function EntityRowsDemo({ empty = false }: { empty?: boolean }) {
|
||||
const rows = empty
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "agent",
|
||||
leading: <Bot className="h-4 w-4 text-cyan-600" />,
|
||||
identifier: "agent",
|
||||
title: "CodexCoder",
|
||||
subtitle: "Senior Product Engineer · active in Storybook worktree",
|
||||
trailing: <StatusBadge status="running" />,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
id: "issue",
|
||||
leading: <FolderKanban className="h-4 w-4 text-emerald-600" />,
|
||||
identifier: "PAP-1677",
|
||||
title: "Storybook: Data Visualization & Misc stories",
|
||||
subtitle: "Medium priority · Board UI project",
|
||||
trailing: <Badge variant="secondary">UI</Badge>,
|
||||
},
|
||||
{
|
||||
id: "approval",
|
||||
leading: <ShieldCheck className="h-4 w-4 text-amber-600" />,
|
||||
identifier: "approval",
|
||||
title: "Publish Storybook preview",
|
||||
subtitle: "Approved for internal design review",
|
||||
trailing: <CheckCircle2 className="h-4 w-4 text-emerald-600" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="EntityRow" title={empty ? "Empty list container" : "Generic list rows"}>
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-background/70">
|
||||
{rows.map((row) => (
|
||||
<EntityRow
|
||||
key={row.id}
|
||||
leading={row.leading}
|
||||
identifier={row.identifier}
|
||||
title={row.title}
|
||||
subtitle={row.subtitle}
|
||||
trailing={row.trailing}
|
||||
selected={row.selected}
|
||||
to={row.id === "issue" ? "/PAP/issues/PAP-1677" : undefined}
|
||||
/>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<div className="p-6 text-sm text-muted-foreground">No entities match this view.</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SwipeToArchiveDemo({ disabled = false }: { disabled?: boolean }) {
|
||||
const [archived, setArchived] = useState(false);
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="SwipeToArchive" title={disabled ? "Disabled mobile gesture" : "Mobile archive gesture"}>
|
||||
<div className="mx-auto w-full max-w-sm overflow-hidden rounded-2xl border border-border bg-background shadow-sm">
|
||||
<div className="border-b border-border px-4 py-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Inbox
|
||||
</div>
|
||||
{archived ? (
|
||||
<div className="flex items-center justify-center gap-2 p-8 text-sm text-muted-foreground">
|
||||
<Archive className="h-4 w-4" />
|
||||
Archived
|
||||
</div>
|
||||
) : (
|
||||
<SwipeToArchive
|
||||
selected
|
||||
disabled={disabled}
|
||||
onArchive={() => setArchived(true)}
|
||||
>
|
||||
<EntityRow
|
||||
leading={<Play className="h-4 w-4 text-cyan-600" />}
|
||||
identifier="PAP-1677"
|
||||
title="Storybook: Data Visualization & Misc stories"
|
||||
subtitle={disabled ? "Gesture disabled while review is locked" : "Swipe left on touch devices to archive"}
|
||||
trailing={<Badge variant="outline">mobile</Badge>}
|
||||
/>
|
||||
</SwipeToArchive>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="m-3"
|
||||
onClick={() => setArchived(false)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyPatternIconMatrix() {
|
||||
const companies = [
|
||||
{ name: "Paperclip Storybook", color: "#0f766e" },
|
||||
{ name: "Research Bureau", color: "#2563eb" },
|
||||
{ name: "Launch Ops", color: "#c2410c" },
|
||||
{ name: "Atlas Finance", color: "#7c3aed" },
|
||||
];
|
||||
const sizes = ["h-8 w-8 text-xs", "h-11 w-11 text-base", "h-16 w-16 text-xl", "h-24 w-24 text-3xl"];
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="CompanyPatternIcon" title="Generated company pattern icons by size">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{companies.map((company) => (
|
||||
<Card key={company.name} className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{company.name}</CardTitle>
|
||||
<CardDescription>{company.color}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-end gap-4">
|
||||
{sizes.map((size) => (
|
||||
<CompanyPatternIcon
|
||||
key={size}
|
||||
companyName={company.name}
|
||||
brandColor={company.color}
|
||||
className={size}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function AsciiArtAnimationDemo({ loading = false }: { loading?: boolean }) {
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="AsciiArtAnimation" title={loading ? "Loading art surface" : "Animated ASCII paperclip field"}>
|
||||
<div className="h-[360px] overflow-hidden rounded-xl border border-border bg-background">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Preparing animation canvas
|
||||
</div>
|
||||
) : (
|
||||
<AsciiArtAnimation />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PageSkeletonMatrix() {
|
||||
const variants = [
|
||||
"list",
|
||||
"issues-list",
|
||||
"detail",
|
||||
"dashboard",
|
||||
"approvals",
|
||||
"costs",
|
||||
"inbox",
|
||||
"org-chart",
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<StoryShell>
|
||||
<Section eyebrow="PageSkeleton" title="Loading skeletons for page layouts">
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
{variants.map((variant) => (
|
||||
<Card key={variant} className="shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{variant}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-[420px] overflow-hidden">
|
||||
<PageSkeleton variant={variant} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</StoryShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Data Visualization & Misc",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Fixture-backed stories for charting, board, filtering, live run, onboarding, package preview, entity row, mobile gesture, generated icon, ASCII animation, and skeleton states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const ActivityChartsPopulated: Story = {
|
||||
name: "ActivityCharts / Populated",
|
||||
render: () => <ActivityChartsMatrix />,
|
||||
};
|
||||
|
||||
export const ActivityChartsEmpty: Story = {
|
||||
name: "ActivityCharts / Empty",
|
||||
render: () => <ActivityChartsMatrix empty />,
|
||||
};
|
||||
|
||||
export const KanbanBoardPopulated: Story = {
|
||||
name: "KanbanBoard / Populated",
|
||||
render: () => <KanbanBoardDemo />,
|
||||
};
|
||||
|
||||
export const KanbanBoardEmpty: Story = {
|
||||
name: "KanbanBoard / Empty",
|
||||
render: () => <KanbanBoardDemo empty />,
|
||||
};
|
||||
|
||||
export const FilterBarPopulated: Story = {
|
||||
name: "FilterBar / Populated",
|
||||
render: () => <FilterBarDemo />,
|
||||
};
|
||||
|
||||
export const FilterBarEmpty: Story = {
|
||||
name: "FilterBar / Empty",
|
||||
render: () => <FilterBarDemo empty />,
|
||||
};
|
||||
|
||||
export const LiveRunWidgetPopulated: Story = {
|
||||
name: "LiveRunWidget / Populated",
|
||||
render: () => <LiveRunWidgetStory />,
|
||||
};
|
||||
|
||||
export const LiveRunWidgetLoading: Story = {
|
||||
name: "LiveRunWidget / Loading",
|
||||
render: () => <LiveRunWidgetStory loading />,
|
||||
};
|
||||
|
||||
export const LiveRunWidgetEmpty: Story = {
|
||||
name: "LiveRunWidget / Empty",
|
||||
render: () => <LiveRunWidgetStory empty />,
|
||||
};
|
||||
|
||||
export const OnboardingWizardCompanyStep: Story = {
|
||||
name: "OnboardingWizard / Company Step",
|
||||
render: () => <OpenOnboardingOnMount initialStep={1} />,
|
||||
};
|
||||
|
||||
export const OnboardingWizardAgentStep: Story = {
|
||||
name: "OnboardingWizard / Agent Step",
|
||||
render: () => <OpenOnboardingOnMount initialStep={2} />,
|
||||
};
|
||||
|
||||
export const PackageFileTreePopulated: Story = {
|
||||
name: "PackageFileTree / Populated",
|
||||
render: () => <PackageFileTreeDemo />,
|
||||
};
|
||||
|
||||
export const PackageFileTreeEmpty: Story = {
|
||||
name: "PackageFileTree / Empty",
|
||||
render: () => <PackageFileTreeDemo empty />,
|
||||
};
|
||||
|
||||
export const EntityRowPopulated: Story = {
|
||||
name: "EntityRow / Populated",
|
||||
render: () => <EntityRowsDemo />,
|
||||
};
|
||||
|
||||
export const EntityRowEmpty: Story = {
|
||||
name: "EntityRow / Empty",
|
||||
render: () => <EntityRowsDemo empty />,
|
||||
};
|
||||
|
||||
export const SwipeToArchiveMobile: Story = {
|
||||
name: "SwipeToArchive / Mobile",
|
||||
render: () => <SwipeToArchiveDemo />,
|
||||
};
|
||||
|
||||
export const SwipeToArchiveDisabled: Story = {
|
||||
name: "SwipeToArchive / Disabled",
|
||||
render: () => <SwipeToArchiveDemo disabled />,
|
||||
};
|
||||
|
||||
export const CompanyPatternIconSizes: Story = {
|
||||
name: "CompanyPatternIcon / Sizes",
|
||||
render: () => <CompanyPatternIconMatrix />,
|
||||
};
|
||||
|
||||
export const AsciiArtAnimationPopulated: Story = {
|
||||
name: "AsciiArtAnimation / Populated",
|
||||
render: () => <AsciiArtAnimationDemo />,
|
||||
};
|
||||
|
||||
export const AsciiArtAnimationLoading: Story = {
|
||||
name: "AsciiArtAnimation / Loading",
|
||||
render: () => <AsciiArtAnimationDemo loading />,
|
||||
};
|
||||
|
||||
export const PageSkeletonLayouts: Story = {
|
||||
name: "PageSkeleton / Layouts",
|
||||
render: () => <PageSkeletonMatrix />,
|
||||
};
|
||||
@@ -102,7 +102,7 @@ const coverageRows = [
|
||||
["Budget & finance", "Planned", "Incident cards, provider quotas, biller spend, subscription panels"],
|
||||
["Dialogs & modals", "Planned", "New issue/agent/goal/project dialogs, diff modal, image gallery"],
|
||||
["Projects & goals", "Planned", "Project properties, workspace cards, goal trees, runtime controls"],
|
||||
["Chat & comments", "Planned", "Comment threads, run chat, issue chat with timeline events"],
|
||||
["Chat & comments", "Covered", "Comment threads, run chat, issue chat with timeline events"],
|
||||
["Data viz & misc", "Planned", "Activity charts, kanban, filter bar, live widget, onboarding, skeletons"],
|
||||
["Full app pages", "Deferred", "API-driven route stories after page data loaders can be fixture-injected"],
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user