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:
DottaandPaperclip committed 2026-04-20 11:01:03 -05:00
1 parent 09a97605f9
commit ae627978fd
4 files changed
+1819 -37

No files matched your search

+352 -36
View File
@@ -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 />,
};
+1 -1
View File
@@ -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"],
];