test(storybook): add production examples for feature map UI gaps (#16196)

## Thinking Path

> - Paperclip manages AI agents and their work.
> - The feature map records the user-facing capabilities.
> - Storybook uses the same feature index.
> - Step 2 found 49 UI capabilities with no authored examples.
> - This pull request adds shipped-component examples with isolated
fixtures.
> - The catalog records each example's states and remaining production
checks.

## Linked Issues or Issue Description

Refs: #16167. The verification inventory is merged; this follow-up now
targets master.

## What Changed

- Add 37 suites and 257 scenarios for all 49 UI capabilities from the
step-2 inventory. Keep existing stories and IDs.
- Use shipped pages and components. Simulate API replies, sockets,
props, and callbacks at their boundaries.
- Activate story API fixtures before the application providers read
data. Block unregistered API mutations and restore owned storage/fetch
state.
- Add browser checks for rendering, declared play assertions, fixture
reset, and selected mobile/theme states. Update the catalog and
verification inventory.

## Verification

- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- UI Vitest suite: 730 files and 8,208 tests passed. Fixture boundary
tests: 4 passed.
- Map/catalog checks, 27 generator/map tests, and token gates: passed.
- `pnpm build-storybook`: passed. All 2,248 previous built entry IDs
remain present.
- Pre-reconciliation static browser suite: 260 checks passed (257
scenarios, token fixture reset, and two mobile/theme cases). All
non-play stories assert an explicit subject state; 129 play functions
passed.
- `pnpm test:run` could not complete on this Mac. Embedded PostgreSQL
exhausted host shared-memory allocation. The run also reported
native-runner test timeouts. Only this task's verified runner was
stopped; other jobs and host settings were preserved.
- Reconciled onto merged master `252d9b5077`. The six draft stories and
new same-browser reset regression passed (7 checks); 16
draft-persistence tests and UI typecheck passed. Static merge
verification: 32 affected-subject/draft checks passed, including
same-browser reset. Rebuilt Storybook retains all 2,248 original IDs in
its 2,517-entry index. Four additional play-result checks passed:
visible onboarding reset, pending probe, filtered-empty runs, and
optimistic queued-response removal. UI typecheck and 27 catalog/map
tests also passed. Final static browser suite on
`3fab35df8d912935fa86ce0de7d32d07e2b5f4c3`: all 261 checks passed (257
scenarios plus fixture-reset and mobile checks). Latest head
`3fab35df8d912935fa86ce0de7d32d07e2b5f4c3`: all 55 CI/status checks
passed; the two optional Storybook workflow jobs were skipped. Greptile
5/5 with no unresolved threads.
- No pinned Linux visual-baseline comparison or real provider journey is
claimed.

## Risks

Fixtures can drift from production contracts. The catalog records
inspected sources and their fingerprints. Capability links provide
initial isolated UI coverage; they do not certify every recipe variant,
real permissions, persistence, or provider behavior.

The new examples expose existing UI limits. Plugin discovery failure
stays on Loading. Document and environment-list reads lack dedicated
failure feedback; Cases and Inbox fall back to empty views after errors.
Goal alignment has display views but no assignment control. These
findings remain visible in the inventory.

## Model Used

OpenAI GPT-6 via Codex. The exact runtime deployment ID and context
window are not exposed in this session. Used code generation, shell
execution, and browser verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-13 13:24:09 -05:00
1 parent 252d9b5077
commit c8efdbfba4
54 files changed
+6759 -73

No files matched your search

+2
View File
@@ -1,3 +1,4 @@
import { activatePreparedFixtures } from "../support/capability-gaps/api";
import { useLayoutEffect, useState, type ReactNode } from "react";
import type { Preview } from "@storybook/react-vite";
import { MINIMAL_VIEWPORTS } from "storybook/viewport";
@@ -764,6 +765,7 @@ function StorybookProviders({
if (typeof window !== "undefined") {
installStorybookApiFixtures();
activatePreparedFixtures();
}
const [viewportReady, setViewportReady] = useState(() => !initialViewportWidth || window.parent === window || window.innerWidth === initialViewportWidth);
@@ -0,0 +1,125 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { CliAuthPage } from "@/pages/CliAuth";
import { expect, userEvent, within } from "storybook/test";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function CliAuthorization() {
return (
<Routes>
<Route path="/cli-auth/:id" element={<CliAuthPage />} />
</Routes>
);
}
const meta = {
id: "feature-cli-authorization",
title:
"Getting started and access/Login, invitations, and access/CLI authorization",
component: CliAuthorization,
parameters: {
initialEntries: ["/cli-auth/challenge-gap?token=fixture-code"],
layout: "padded",
},
beforeEach: ({ parameters }) => {
let status = parameters.status ?? "pending";
return prepareFixtureApi(
[
{
path: "/api/cli-auth/challenges/challenge-gap",
read: () => ({
id: "challenge-gap",
status,
command: "paperclipai auth login",
clientName: "Review workstation",
requestedAccess: "board",
requestedCompanyId: "company-storybook",
requestedCompanyName: "Acme Research",
approvedAt: null,
expiresAt: "2099-01-01T00:00:00Z",
requiresSignIn: false,
canApprove: !parameters.blocked,
}),
},
{
path: "/api/cli-auth/challenges/challenge-gap/approve",
method: "POST",
read: () => {
status = "approved";
return {
approved: true,
status,
userId: "user-storybook",
keyId: "fixture-key",
expiresAt: "2099-01-01T00:00:00Z",
};
},
},
{
path: "/api/cli-auth/challenges/challenge-gap/cancel",
method: "POST",
read: () => {
status = "cancelled";
return { cancelled: true, status };
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof CliAuthorization>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Pending: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Expired: Story = { parameters: { status: "expired" } };
export const Unavailable: Story = { parameters: { fixtureState: "failure" } };
export const PermissionBlocked: Story = {
parameters: { blocked: true },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("button", {
name: "Approve CLI access",
}),
).toBeDisabled();
},
};
export const Approved: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Approve CLI access" }),
);
await c.findByRole("heading", { name: "CLI access approved" });
},
};
export const Approving: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Approve CLI access" }),
);
await expect(
await c.findByRole("button", { name: "Approving..." }),
).toBeDisabled();
},
};
export const ApprovalFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Approve CLI access" }),
);
await c.findByText("The request failed. Please try again.");
},
};
export const Cancelled: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Cancel" }));
await c.findByRole("heading", { name: "CLI auth challenge cancelled" });
},
};
@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ToastViewport } from "@/components/ToastViewport";
import { CompanyAccess } from "@/pages/CompanyAccess";
import { expect, userEvent, within, waitFor } from "storybook/test";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
let member = {
id: "member-gap",
companyId: "company-storybook",
principalType: "user",
principalId: "user-review",
status: "active",
membershipRole: "operator",
user: {
id: "user-review",
email: "reviewer@example.test",
name: "Release reviewer",
image: null,
},
grants: [],
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
function MembershipHost() {
return (
<>
<CompanyAccess />
<ToastViewport />
</>
);
}
const meta = {
id: "feature-membership",
title:
"Getting started and access/Login, invitations, and access/Organization membership",
component: MembershipHost,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
member = { ...member, membershipRole: "operator", status: "active" };
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/members",
read: () => ({
members: parameters.fixtureState === "empty" ? [] : [member],
access: {
currentUserRole: "owner",
canManageMembers: true,
canInviteUsers: true,
canApproveJoinRequests: true,
},
}),
},
{
path: "/api/companies/company-storybook/join-requests",
read: () => [],
},
{
path: "/api/companies/company-storybook/members/member-gap",
method: "PATCH",
read: ({ body }) => {
member = { ...member, ...body };
return member;
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof MembershipHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Members: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const UpdateRole: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Edit" }),
);
const c = within(document.body);
await userEvent.selectOptions(
c.getByRole("combobox", { name: "Organization role" }),
"viewer",
);
await userEvent.click(c.getByRole("button", { name: "Save member" }));
await waitFor(() => expect(member.membershipRole).toBe("viewer"));
await waitFor(() =>
expect(c.queryByRole("dialog")).not.toBeInTheDocument(),
);
},
};
export const SaveFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Edit" }),
);
const c = within(document.body);
await userEvent.selectOptions(
c.getByRole("combobox", { name: "Organization role" }),
"viewer",
);
await userEvent.click(c.getByRole("button", { name: "Save member" }));
await c.findByText("The request failed. Please try again.");
},
};
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { AuthPage } from "@/pages/Auth";
import { Companies } from "@/pages/Companies";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { storybookAuthSession } from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function SignIn() {
return (
<Routes>
<Route path="/auth" element={<AuthPage />} />
<Route path="/companies" element={<Companies />} />
<Route path="/PAP/companies" element={<Companies />} />
</Routes>
);
}
const meta = {
id: "feature-sign-in",
title: "Getting started and access/Login, invitations, and access/Sign in",
component: SignIn,
parameters: {
layout: "fullscreen",
initialEntries: ["/auth?next=/companies"],
},
beforeEach: ({ parameters }) => {
let session: unknown = null;
return prepareFixtureApi(
[
{
path: "/api/health",
state: "ready",
read: () => ({
status: "ok",
deploymentMode: "authenticated",
authReady: true,
}),
},
{ path: "/api/auth/get-session", read: () => session },
{
path: "/api/auth/sign-in/email",
method: "POST",
read: () => {
session = storybookAuthSession;
return { user: storybookAuthSession.user, token: "fixture-only" };
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof SignIn>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SignedOut: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const SessionFailed: Story = { parameters: { fixtureState: "failure" } };
async function submit(canvasElement: HTMLElement) {
const c = within(canvasElement);
const email = await c.findByRole("textbox", { name: "Email" });
await userEvent.clear(email);
await userEvent.click(email);
await userEvent.paste("reviewer@example.test");
const password = c.getByLabelText("Password");
await userEvent.clear(password);
await userEvent.click(password);
await userEvent.paste("fixture-password");
await waitFor(() => {
expect(c.getByRole("textbox", { name: "Email" })).toHaveValue(
"reviewer@example.test",
);
expect(c.getByLabelText("Password")).toHaveValue("fixture-password");
expect(c.getByRole("button", { name: "Sign In" })).toHaveAttribute(
"aria-disabled",
"false",
);
});
await userEvent.click(c.getByRole("button", { name: "Sign In" }));
}
export const SignInFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await submit(canvasElement);
await within(canvasElement).findByText(
"The request failed. Please try again.",
);
},
};
export const SigningIn: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
await submit(canvasElement);
await expect(
await within(canvasElement).findByRole("button", { name: "Working…" }),
).toBeDisabled();
},
};
export const SignedIn: Story = {
play: async ({ canvasElement }) => {
await submit(canvasElement);
await within(canvasElement).findByRole("button", {
name: "New Organization",
});
},
};
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { AuditRuns } from "@/pages/audit/AuditRuns";
import {
storybookLiveRuns,
storybookAgents,
} from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const meta = {
id: "feature-run-audit",
title:
"Oversight and operation/Dashboards and audit trails/Execution history",
component: AuditRuns,
args: { companyId: "company-storybook" },
parameters: { layout: "padded" },
beforeEach: ({ parameters }) =>
prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/heartbeat-runs",
read: () =>
parameters.fixtureState === "empty"
? []
: [
{
...storybookLiveRuns[0],
id: "run-gap",
agentId: storybookAgents[0].id,
status: "succeeded",
invocationSource: "on_demand",
startedAt: "2026-10-12T12:00:00Z",
finishedAt: "2026-10-12T12:01:00Z",
resultJson: { summary: "Reviewed the release checklist" },
},
],
},
],
parameters.fixtureState as FixtureState,
).cleanup,
} satisfies Meta<typeof AuditRuns>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CompletedRun: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Reviewed the release checklist");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = {
parameters: { fixtureState: "failure" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"The request failed. Please try again.",
);
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"You do not have permission to perform this action.",
);
},
};
export const FilterNoMatches: Story = {
parameters: { initialEntries: ["/PAP/audit?runStatus=failed"] },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("button", {
name: /Clear filters/,
}),
).toBeEnabled();
const c = within(canvasElement);
await expect(
await c.findByText("No runs match these filters."),
).toBeVisible();
await expect(
c.queryByText("Reviewed the release checklist"),
).not.toBeInTheDocument();
},
};
@@ -0,0 +1,120 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BoardChat } from "@/pages/BoardChat";
import { expect, userEvent, within } from "storybook/test";
import { storybookIssues } from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const meta = {
id: "feature-conference-room",
title:
"Tasks and conversations/Agent conversations and project handoff/Conference Room",
component: BoardChat,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Shipped Conference Room with isolated conversation receipts and SSE. This does not execute a provider or verify server access.",
},
},
},
beforeEach: ({ parameters }) => {
let comments: unknown[] = [];
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/issues",
read: () => [
{
...storybookIssues[0],
id: "conference-gap",
title: "Board Operations",
},
],
},
{ path: "/api/issues/conference-gap/comments", read: () => comments },
{ path: "/api/issues/conference-gap/feedback-votes", read: () => [] },
{
path: "/api/board/chat/stream",
method: "POST",
read: ({ body }) => {
comments = [
{
id: "comment-human",
issueId: "conference-gap",
companyId: "company-storybook",
authorUserId: "user-storybook",
authorAgentId: null,
body: String(body.message),
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
},
{
id: "comment-agent",
issueId: "conference-gap",
companyId: "company-storybook",
authorUserId: null,
authorAgentId: "agent-codex",
body: "The release is ready for review.",
createdAt: "2026-10-12T12:00:01Z",
updatedAt: "2026-10-12T12:00:01Z",
},
];
return new Response(
'data: {"type":"chunk","text":"The release is ready for review."}\n\ndata: {"type":"done"}\n\n',
{ headers: { "Content-Type": "text/event-stream" } },
);
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof BoardChat>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = {};
async function send(canvasElement: HTMLElement) {
const c = within(canvasElement);
await userEvent.type(await c.findByRole("textbox"), "Review the release", {
delay: 15,
});
await userEvent.click(c.getByRole("button", { name: "Send message" }));
}
export const Responded: Story = {
play: async ({ canvasElement }) => {
await send(canvasElement);
await expect(
await within(canvasElement).findByText(
"The release is ready for review.",
),
).toBeVisible();
},
};
export const Sending: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
await send(canvasElement);
await within(canvasElement).findByText("Connecting...");
},
};
export const SendFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await send(canvasElement);
await within(canvasElement).findByText(
"The board assistant is unavailable right now. Please try again in a moment.",
);
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "mutation-permission" },
play: async ({ canvasElement }) => {
await send(canvasElement);
await within(canvasElement).findByText(
"The board assistant is unavailable right now. Please try again in a moment.",
);
},
};
@@ -0,0 +1,113 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ToolCatalogEntry } from "@paperclipai/shared";
import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
import { expect, userEvent, within } from "storybook/test";
const entry: ToolCatalogEntry = {
id: "catalog-gap",
companyId: "company-storybook",
applicationId: null,
connectionId: "connection-gap",
entryKind: "tool",
toolName: "publish_release",
title: "Publish release",
description: "Publish the approved release notes.",
inputSchema: { type: "object" },
outputSchema: null,
annotations: null,
riskLevel: "write",
isReadOnly: false,
isWrite: true,
isDestructive: false,
status: "quarantined",
addedAt: new Date("2026-10-12"),
version: null,
schemaHash: null,
firstSeenAt: new Date("2026-10-12"),
lastSeenAt: new Date("2026-10-12"),
reviewedAt: null,
reviewedByAgentId: null,
reviewedByUserId: null,
createdAt: new Date("2026-10-12"),
updatedAt: new Date("2026-10-12"),
};
function CatalogReview({
state = "ready",
}: {
state?: "ready" | "empty" | "refreshing" | "saving" | "readonly";
}) {
const [quarantined, setQuarantined] = useState(
state === "empty" ? [] : [entry],
);
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [refreshing, setRefreshing] = useState(state === "refreshing");
return (
<ActionsSection
connectionId="connection-gap"
appName="Release tools"
readOnly={[]}
canChange={
state === "empty" || quarantined.length
? []
: [{ ...entry, status: "active" }]
}
quarantined={quarantined}
enabledIds={enabled}
askFirstIds={new Set()}
disabled={state === "saving"}
refreshPending={refreshing}
canConfigure={state !== "readonly"}
onReviewQuarantined={(ids) => {
setEnabled(new Set(ids));
setQuarantined([]);
}}
onRefreshActions={() => {
setRefreshing(false);
setQuarantined([entry]);
}}
onSetPermission={(ids, next) =>
setEnabled(new Set(next === "allowed" ? ids : []))
}
/>
);
}
const meta = {
id: "feature-catalog-review",
title:
"Apps, channels, and extensions/App access and action permissions/Review newly discovered actions",
component: CatalogReview,
parameters: {
layout: "padded",
docs: {
description: {
component:
"Shipped action section and quarantine reviewer with local catalog props. Discovery failures belong to the owning AppDetail API boundary; permissions and review persistence need production checks.",
},
},
},
} satisfies Meta<typeof CatalogReview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Quarantined: Story = {};
export const Empty: Story = { args: { state: "empty" } };
export const Refreshing: Story = { args: { state: "refreshing" } };
export const Saving: Story = { args: { state: "saving" } };
export const ReadOnly: Story = { args: { state: "readonly" } };
export const ReviewAndAllow: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
c.getByRole("switch", { name: "Publish release allowed" }),
);
await userEvent.click(c.getByRole("button", { name: "Save choices" }));
await expect(c.queryByText("Review 1 new action")).not.toBeInTheDocument();
await c.findByText("Publish release");
},
};
export const LeaveBlocked: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(c.getByRole("button", { name: "Save choices" }));
await expect(c.queryByText("Review 1 new action")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,153 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { BudgetIncident } from "@paperclipai/shared";
import { expect, userEvent, within, waitFor } from "storybook/test";
import type { ComponentProps } from "react";
import { Costs } from "@/pages/Costs";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const incident: BudgetIncident = {
id: "budget-gap",
companyId: "company-storybook",
policyId: "policy-gap",
scopeType: "company",
scopeId: "company-storybook",
scopeName: "Acme Research",
metric: "billed_cents",
windowKind: "calendar_month_utc",
windowStart: new Date("2026-10-01"),
windowEnd: new Date("2026-11-01"),
thresholdType: "hard",
amountLimit: 10000,
amountObserved: 12000,
status: "open",
approvalId: null,
approvalStatus: null,
resolvedAt: null,
createdAt: new Date("2026-10-12"),
updatedAt: new Date("2026-10-12"),
};
let currentIncident: BudgetIncident;
function BudgetHost(props: NonNullable<ComponentProps<typeof Costs>>) {
return <Costs {...props} />;
}
const meta = {
id: "feature-budget-resolution",
title: "Oversight and operation/Costs and budgets/Resolve a budget incident",
component: BudgetHost,
args: { embedded: true, initialTab: "budgets", lockTab: true },
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
currentIncident = { ...incident };
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/budgets/overview",
read: () => ({
companyId: "company-storybook",
policies: [],
activeIncidents:
parameters.fixtureState === "empty" ||
currentIncident.status !== "open"
? []
: [currentIncident],
pausedAgentCount: parameters.fixtureState==="empty"?0:currentIncident.status === "open" ? 1 : 0,
pausedProjectCount: 0,
pendingApprovalCount: 0,
}),
},
{
path: "/api/companies/company-storybook/budget-incidents/budget-gap/resolve",
method: "POST",
read: ({ body }) => {
currentIncident = {
...currentIncident,
status: body.action === "keep_paused" ? "dismissed" : "resolved",
resolvedAt: new Date(),
amountLimit: Number(body.amount ?? currentIncident.amountLimit),
};
return currentIncident;
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof BudgetHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const DecisionRequired: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("button", {
name: "Raise budget & resume",
});
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const InvalidAmount: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await c.findByRole("button", { name: "Raise budget & resume" });
const input = c.getByPlaceholderText("0.00");
await userEvent.clear(input);
await userEvent.type(input, "100", { delay: 15 });
await expect(
c.getByRole("button", { name: "Raise budget & resume" }),
).toBeDisabled();
},
};
export const RaiseAndResume: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Raise budget & resume" }),
);
await waitFor(() => expect(currentIncident.status).toBe("resolved"));
await waitFor(() =>
expect(
c.queryByRole("button", { name: "Raise budget & resume" }),
).not.toBeInTheDocument(),
);
},
};
export const KeepPaused: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Keep paused" }),
);
await waitFor(() => expect(currentIncident.status).toBe("dismissed"));
},
};
export const Saving: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Raise budget & resume" }),
);
await expect(
await c.findByRole("button", { name: "Applying..." }),
).toBeDisabled();
},
};
export const SaveFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Raise budget & resume" }),
);
await c.findByRole("alert");
await expect(
c.getByRole("button", { name: "Raise budget & resume" }),
).toBeEnabled();
},
};
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Cases } from "@/pages/Cases";
import { within } from "storybook/test";
import { installCaseFixtures } from "../../support/capability-gaps/cases";
import type { FixtureState } from "../../support/capability-gaps/api";
const meta = {
id: "feature-case-browse",
title: "Automation and structured work/Cases/Browse and hierarchy",
component: Cases,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
const key = "paperclip:cases:company-storybook:view";
const previous = localStorage.getItem(key);
localStorage.removeItem(key);
const cleanup = installCaseFixtures(
parameters.fixtureState as FixtureState,
);
return () => {
cleanup();
if (previous) localStorage.setItem(key, previous);
else localStorage.removeItem(key);
};
},
} satisfies Meta<typeof Cases>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ReviewCases: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findAllByText("Release readiness");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { CaseDetail } from "@/pages/CaseDetail";
import { CaseFieldsPanel } from "@/components/CaseFieldsPanel";
import { IssueCasesPanel } from "@/components/IssueCasesPanel";
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { expect, userEvent, within } from "storybook/test";
import {
caseItem,
installCaseFixtures,
} from "../../support/capability-gaps/cases";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function CaseDetailHost() {
return (
<Routes>
<Route path="/PAP/cases/:caseIdentifier" element={<CaseDetail />} />
</Routes>
);
}
const meta = {
id: "feature-case-detail",
title:
"Automation and structured work/Cases/Structure, history and task links",
component: CaseDetailHost,
parameters: { layout: "padded", initialEntries: ["/PAP/cases/PAP-C42"] },
beforeEach: ({ parameters }) => {
const cleanup = installCaseFixtures(
parameters.fixtureState as FixtureState,
);
const flags = prepareFixtureApi([
{
path: "/api/instance/settings/experimental",
read: () => ({
...instanceExperimentalSettingsSchema.parse({}),
enableCases: true,
}),
},
]);
return () => {
flags.cleanup();
cleanup();
};
},
} satisfies Meta<typeof CaseDetailHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Detail: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("heading", {
name: "Release readiness",
});
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const ActivityHistory: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("tab", { name: /Activity/ }));
await c.findByText("Codex Coder");
},
};
export const ArbitraryFields: Story = {
render: () => <CaseFieldsPanel fields={caseItem.fields} />,
};
export const EmptyFields: Story = {
render: () => <CaseFieldsPanel fields={{}} />,
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("No fields set");
},
};
export const LinkedFromTask: Story = {
render: () => <IssueCasesPanel issueId="task-gap" />,
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("link", {
name: /Release readiness/,
}),
).toHaveAttribute("href", "/PAP/cases/PAP-C42");
},
};
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { IssueAttachment } from "@paperclipai/shared";
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
import { expect, within, waitFor } from "storybook/test";
const attachment: IssueAttachment = {
id: "attachment-gap",
companyId: "company-storybook",
issueId: "task-gap",
issueCommentId: "message-gap",
assetId: "asset-gap",
contentPath: "/api/assets/asset-gap/content",
provider: "paperclip",
objectKey: "fixture/report.txt",
contentType: "text/plain",
byteSize: 42,
sha256: "fixture-sha256",
originalFilename: "release-report.txt",
createdByAgentId: null,
createdByUserId: "user-storybook",
createdAt: new Date("2026-10-12"),
updatedAt: new Date("2026-10-12"),
};
const meta = {
id: "feature-channel-attachments",
title:
"Apps, channels, and extensions/External chat and email/Task-linked files",
component: TaskChatBubble,
args: {
item: {
id: "message-gap",
kind: "message",
author: "human",
text: "Here is the release report.",
sourceChannel: "imessage-photon",
},
attachments: [attachment],
},
parameters: {
layout: "padded",
docs: {
description: {
component:
"Production task bubble with the saved attachment receipt and channel attribution. Provider upload, delivery, download authority and replay require production tests.",
},
},
},
} satisfies Meta<typeof TaskChatBubble>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ReceivedFile: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("release-report.txt");
},
};
export const NoFiles: Story = { args: { attachments: [] } };
export const MultipleFiles: Story = {
args: {
attachments: [
attachment,
{
...attachment,
id: "attachment-two",
assetId: "asset-two",
contentPath: "/api/assets/asset-two/content",
originalFilename: "checks.json",
contentType: "application/json",
},
],
},
};
export const MissingFilename: Story = {
args: { attachments: [{ ...attachment, originalFilename: null }] },
};
export const OutboundFile: Story = {
args: {
item: {
id: "message-gap",
kind: "message",
author: "agent",
text: "I attached the reviewed release report.",
},
},
play: async ({ canvasElement }) => {
const title = await within(canvasElement).findByText("release-report.txt");
await waitFor(() => expect(title).toBeVisible());
},
};
@@ -0,0 +1,126 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CompanyImport } from "@/pages/CompanyImport";
import { expect, userEvent, within } from "storybook/test";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const files = {
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/reviewer/AGENTS.md": "# Reviewer\n\nReview release evidence.",
};
const preview = {
include: { company: true, agents: true, projects: true, issues: true },
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["reviewer"],
plan: {
companyAction: "create",
agentPlans: [
{
slug: "reviewer",
action: "create",
plannedName: "Reviewer",
existingAgentId: null,
reason: null,
},
],
projectPlans: [],
issuePlans: [],
},
manifest: {
agents: [
{
slug: "reviewer",
name: "Reviewer",
path: "agents/reviewer/AGENTS.md",
adapterType: "claude_local",
},
],
projects: [],
issues: [],
skills: [],
company: null,
},
files,
envInputs: [],
warnings: [],
errors: [],
};
const meta = {
id: "feature-company-import",
title: "Getting started and access/Companies and portability/Import preview",
component: CompanyImport,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) =>
prepareFixtureApi(
[
{
path: "/api/companies/import/preview",
method: "POST",
read: () => ({
...preview,
warnings: parameters.warning
? ["Provider credentials are not included in this package."]
: [],
}),
},
],
parameters.fixtureState as FixtureState,
).cleanup,
} satisfies Meta<typeof CompanyImport>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ChooseSource: Story = {};
async function previewPackage(canvasElement: HTMLElement) {
const c = within(canvasElement);
await userEvent.type(
await c.findByPlaceholderText(
"https://github.com/owner/repo/tree/main/company",
),
"https://github.com/example/release-team",
{ delay: 15 },
);
await userEvent.click(c.getByRole("button", { name: "Preview import" }));
}
export const PreviewReady: Story = {
play: async ({ canvasElement }) => {
await previewPackage(canvasElement);
await within(canvasElement).findByText("Reviewer");
},
};
export const PreviewWithWarning: Story = {
parameters: { warning: true },
play: async ({ canvasElement }) => {
await previewPackage(canvasElement);
await within(canvasElement).findByText(
"Provider credentials are not included in this package.",
);
},
};
export const PreviewLoading: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
await previewPackage(canvasElement);
await expect(
await within(canvasElement).findByRole("button", {
name: "Previewing...",
}),
).toBeDisabled();
},
};
export const PreviewFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await previewPackage(canvasElement);
await within(canvasElement).findByText(/The request failed/);
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "mutation-permission" },
play: async ({ canvasElement }) => {
await previewPackage(canvasElement);
await within(canvasElement).findByText(/You do not have permission/);
},
};
@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Companies } from "@/pages/Companies";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { storybookCompanies } from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const meta = {
id: "feature-company-selection",
title:
"Getting started and access/Companies and portability/Selection and archived organizations",
component: Companies,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
let companies =
parameters.fixtureState === "empty"
? []
: [
...storybookCompanies.map((c) => ({ ...c })),
{
...storybookCompanies[0],
id: "archived-gap",
name: "Archived Research",
issuePrefix: "ARC",
status: "archived",
},
];
return prepareFixtureApi(
[
{ path: "/api/companies", read: () => companies },
{ path: "/api/companies/stats", read: () => ({}) },
{
path: "/api/companies/archived-gap",
method: "PATCH",
read: ({ body }) => {
companies = companies.map((c) =>
c.id === "archived-gap" ? { ...c, ...body } : c,
);
return companies.find((c) => c.id === "archived-gap");
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof Companies>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Organizations: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const SelectOrganization: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const card = await c.findByRole("button", { name: /Archived Research/ });
await userEvent.click(card);
await waitFor(() =>
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe(
"archived-gap",
),
);
},
};
export const Unarchive: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const card = (await c.findByText("Archived Research")).closest(
'[role="button"]',
) as HTMLElement;
await userEvent.click(
card.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')!,
);
await userEvent.click(
await within(document.body).findByRole("menuitem", { name: "Unarchive" }),
);
await waitFor(() => {
const restored = within(canvasElement)
.getByText("Archived Research")
.closest('[role="button"]') as HTMLElement;
expect(within(restored).getByText("active")).toBeVisible();
});
},
};
@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueDocumentsSection } from "@/components/IssueDocumentsSection";
import { expect, userEvent, within } from "storybook/test";
import {
storybookIssues,
storybookIssueDocuments,
} from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const meta = {
id: "feature-document-revisions",
title:
"Tasks and conversations/Documents, attachments, and work products/Document revisions",
component: IssueDocumentsSection,
args: { issue: storybookIssues[0], canDeleteDocuments: true },
parameters: {
layout: "padded",
docs: {
description: {
component:
"Production document section. Loading and read errors currently yield the section's empty rendering; these examples expose that limitation. Revision authority and persisted concurrency require API tests.",
},
},
},
beforeEach: ({ parameters }) => {
let doc = {
...storybookIssueDocuments[0],
body: "# Release plan\n\nRun the release checks.",
latestRevisionNumber: 2,
latestRevisionId: "revision-latest",
};
const revisions = [
{
id: "revision-latest",
companyId: doc.companyId,
documentId: doc.id,
issueId: doc.issueId,
key: doc.key,
revisionNumber: 2,
title: doc.title,
format: "markdown",
body: doc.body,
changeSummary: "Add checks",
createdByAgentId: null,
createdByUserId: "user-storybook",
createdAt: "2026-10-12T12:00:00Z",
},
{
id: "revision-first",
companyId: doc.companyId,
documentId: doc.id,
issueId: doc.issueId,
key: doc.key,
revisionNumber: 1,
title: doc.title,
format: "markdown",
body: "# Original release plan",
changeSummary: "Initial draft",
createdByAgentId: null,
createdByUserId: "user-storybook",
createdAt: "2026-10-11T12:00:00Z",
},
];
return prepareFixtureApi(
[
{
path: `/api/issues/${doc.issueId}/documents`,
read: () => (parameters.fixtureState === "empty" ? [] : [doc]),
},
{
path: `/api/issues/${doc.issueId}/documents/${doc.key}/revisions`,
read: () => revisions,
},
{
path: `/api/issues/${doc.issueId}/documents/${doc.key}`,
read: ({ method, body }) => {
if (method === "PUT")
doc = {
...doc,
body: String(body.body),
latestRevisionNumber: doc.latestRevisionNumber + 1,
};
return doc;
},
},
{
path: `/api/issues/${doc.issueId}/documents/${doc.key}/revisions/revision-first/restore`,
method: "POST",
read: () => {
doc = {
...doc,
body: revisions[1].body,
latestRevisionId: "revision-restored",
latestRevisionNumber: 3,
};
revisions.unshift({
...revisions[0],
id: "revision-restored",
revisionNumber: 3,
body: doc.body,
});
return doc;
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof IssueDocumentsSection>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Current: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Run the release checks.");
},
};
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const ReadFailed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const Edit: Story = { args: { forceEditDocumentKey: "plan" } };
async function previousRevision(canvasElement: HTMLElement) {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: /rev 2/ }),
);
await userEvent.click(
await within(document.body).findByRole("menuitemradio", { name: /rev 1/ }),
);
await within(canvasElement).findByText("Original release plan");
}
export const HistoricalPreview: Story = {
play: async ({ canvasElement }) => {
await previousRevision(canvasElement);
await within(canvasElement).findByRole("button", {
name: "Restore this revision",
});
},
};
export const Restored: Story = {
play: async ({ canvasElement }) => {
await previousRevision(canvasElement);
await userEvent.click(
within(canvasElement).getByRole("button", {
name: "Restore this revision",
}),
);
await within(canvasElement).findByRole("button", { name: /rev 3/ });
await within(canvasElement).findByText("Original release plan");
},
};
export const RestoreFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await previousRevision(canvasElement);
await userEvent.click(
within(canvasElement).getByRole("button", {
name: "Restore this revision",
}),
);
await within(canvasElement).findByText(
"The request failed. Please try again.",
);
},
};
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { CompanyEnvironments } from "@/pages/CompanyEnvironments";
import { ToastViewport } from "@/components/ToastViewport";
import { expect, userEvent, within } from "storybook/test";
import { installEnvironmentFixture } from "../../support/capability-gaps/environments";
import type { FixtureState } from "../../support/capability-gaps/api";
function Environments() {
return (
<>
<Routes>
<Route
path="/PAP/company/settings/instance/environments"
element={<CompanyEnvironments />}
/>
<Route
path="/PAP/company/settings/instance/environments/:environmentId/edit"
element={<CompanyEnvironments mode="edit" />}
/>
</Routes>
<ToastViewport />
</>
);
}
const meta = {
id: "feature-environment-images",
title: "Projects and execution/Execution environments/Custom images",
component: Environments,
parameters: {
layout: "padded",
initialEntries: ["/PAP/company/settings/instance/environments"],
docs: {
description: {
component:
"Production environment admin page. API replies are fictional; no probe connects to a provider. List loading/errors currently fall back to empty saved environments rather than dedicated feedback.",
},
},
},
beforeEach: ({ parameters }) =>
installEnvironmentFixture(
parameters.fixtureState as FixtureState,
parameters.image,
parameters.unsupported,
).cleanup,
} satisfies Meta<typeof Environments>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NoImage: Story = {
parameters: {
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
};
export const CapturedImage: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Active template");
},
parameters: {
image: "captured",
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
};
export const ImageReadFailed: Story = {
parameters: {
image: "failed",
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
};
export const UnsupportedProvider: Story = {
parameters: {
unsupported: true,
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
};
export const ImageLoading: Story = {
parameters: {
image: "loading",
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Loading template setup...");
},
};
export const ImagePermissionDenied: Story = {
parameters: {
image: "denied",
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"You do not have permission to inspect this image.",
);
},
};
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { CompanyEnvironments } from "@/pages/CompanyEnvironments";
import { ToastViewport } from "@/components/ToastViewport";
import { expect, userEvent, within } from "storybook/test";
import { installEnvironmentFixture } from "../../support/capability-gaps/environments";
import type { FixtureState } from "../../support/capability-gaps/api";
function Environments() {
return (
<>
<Routes>
<Route
path="/PAP/company/settings/instance/environments"
element={<CompanyEnvironments />}
/>
<Route
path="/PAP/company/settings/instance/environments/:environmentId/edit"
element={<CompanyEnvironments mode="edit" />}
/>
</Routes>
<ToastViewport />
</>
);
}
const meta = {
id: "feature-execution-environments",
title:
"Projects and execution/Execution environments/Configuration, capabilities, and probes",
component: Environments,
parameters: {
layout: "padded",
initialEntries: ["/PAP/company/settings/instance/environments"],
docs: {
description: {
component:
"Production environment admin page. API replies are fictional; no probe connects to a provider. List loading/errors currently fall back to empty saved environments rather than dedicated feedback.",
},
},
},
beforeEach: ({ parameters }) =>
installEnvironmentFixture(
parameters.fixtureState as FixtureState,
parameters.image,
parameters.unsupported,
).cleanup,
} satisfies Meta<typeof Environments>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Configured: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Release sandbox");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const UnsupportedProvider: Story = { parameters: { unsupported: true } };
export const ProbeSucceeded: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", {
name: "Test provider",
}),
);
await within(document.body).findAllByText("Provider connection succeeded.");
},
};
export const Probing: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", {
name: "Test provider",
}),
);
await expect(
await within(canvasElement).findByRole("button", { name: "Testing..." }),
).toBeDisabled();
},
};
export const ProbeFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", {
name: "Test provider",
}),
);
await within(document.body).findAllByText(
"The request failed. Please try again.",
);
},
};
export const EditConfiguration: Story = {
parameters: {
initialEntries: [
"/PAP/company/settings/instance/environments/environment-gap/edit",
],
},
};
@@ -0,0 +1,220 @@
import { useQuery } from "@tanstack/react-query";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type {
ToolMcpGatewayToken,
ToolMcpGatewayWithTokens,
} from "@paperclipai/shared";
import {
toolMcpGatewayAuthConfigSchema,
toolMcpGatewayHeaderPolicySchema,
toolMcpGatewayMetadataPolicySchema,
toolMcpGatewayOnDemandToolsConfigSchema,
} from "@paperclipai/shared";
import { expect, userEvent, within } from "storybook/test";
import { toolsApi } from "@/api/tools";
import { TokensPanel } from "@/pages/apps/gateways/panels/TokensPanel";
import { gatewaysQueryKey } from "@/pages/apps/gateways/NewGatewayDialog";
import { ToastViewport } from "@/components/ToastViewport";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const token: ToolMcpGatewayToken = {
id: "token-gap",
companyId: "company-storybook",
gatewayId: "gateway-gap",
name: "Preview client",
tokenPrefix: "pcgw_fixture",
subjectType: "gateway_client",
subjectId: null,
clientLabel: "Preview client",
ownerNote: "Local preview",
allowedActions: ["tools/list", "tools/call"],
expiresAt: "2099-12-01T00:00:00Z",
expiryOverrideReason: null,
expiryOverrideByUserId: null,
expiryOverrideByAgentId: null,
expiryOverrideAt: null,
lastUsedAt: null,
revokedAt: null,
createdByAgentId: null,
createdByUserId: "user-storybook",
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
const gateway: ToolMcpGatewayWithTokens = {
id: "gateway-gap",
companyId: "company-storybook",
gatewayPublicId: "preview-gateway",
name: "Preview gateway",
displaySlug: "preview",
slug: "preview",
description: null,
status: "active",
profileId: "profile-gap",
defaultProfileMode: "gateway_only",
contextScopeType: "none",
contextScopeId: null,
agentId: null,
projectId: null,
issueId: null,
approvalIssueId: null,
endpointPath: "/g/preview/mcp",
authConfig: toolMcpGatewayAuthConfigSchema.parse({}),
headerPolicy: toolMcpGatewayHeaderPolicySchema.parse({}),
metadataPolicy: toolMcpGatewayMetadataPolicySchema.parse({}),
onDemandToolsConfig: toolMcpGatewayOnDemandToolsConfigSchema.parse({}),
metadata: null,
createdByAgentId: null,
createdByUserId: "user-storybook",
archivedAt: null,
createdAt: new Date("2026-10-12"),
updatedAt: new Date("2026-10-12"),
tokens: [token],
clientSnippets: [],
};
function Tokens() {
const query = useQuery({
queryKey: gatewaysQueryKey("company-storybook"),
queryFn: () => toolsApi.listGateways("company-storybook"),
});
if (!query.data) return null;
return (
<>
<TokensPanel
companyId="company-storybook"
gateway={query.data.gateways[0]}
/>
<ToastViewport />
</>
);
}
const meta = {
id: "feature-gateway-tokens",
title:
"Apps, channels, and extensions/Tool gateways and access profiles/Named gateway tokens",
component: Tokens,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
let current = {
...gateway,
tokens: parameters.empty
? []
: [
{
...token,
expiresAt: parameters.expired
? "2020-01-01T00:00:00Z"
: token.expiresAt,
},
],
};
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/tools/gateways",
read: () => ({ gateways: [current] }),
},
{
path: "/api/tool-gateway/gateways/gateway-gap/tokens",
method: "POST",
read: ({ body }) => {
const created = {
...token,
id: "issued-token",
name: String(body.name),
clientLabel: String(body.name),
ownerNote: String(body.ownerNote),
expiresAt: String(body.expiresAt),
};
current = { ...current, tokens: [created, ...current.tokens] };
return { ...created, token: "pcgw_storybook_fixture_only" };
},
},
{
path: "/api/tool-gateway/gateway-tokens/token-gap/revoke",
method: "POST",
read: () => {
const revoked = { ...token, revokedAt: "2026-10-13T12:00:00Z" };
current = {
...current,
tokens: current.tokens.map((row) =>
row.id === token.id ? revoked : row,
),
};
return revoked;
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof Tokens>;
export default meta;
type Story = StoryObj<typeof meta>;
export const ActiveToken: Story = {};
export const NoTokens: Story = { parameters: { empty: true } };
export const ExpiredToken: Story = { parameters: { expired: true } };
async function issue(canvasElement: HTMLElement) {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Issue token" }));
await userEvent.clear(c.getByRole("textbox", { name: /Token name/ }));
await userEvent.type(
c.getByRole("textbox", { name: /Token name/ }),
"Review client",
{ delay: 15 },
);
await userEvent.click(
within(canvasElement.querySelector("form")!).getByRole("button", {
name: "Issue token",
}),
);
}
export const IssuedToken: Story = {
play: async ({ canvasElement }) => {
await issue(canvasElement);
await within(document.body).findByText("Token issued");
await within(canvasElement).findByText("pcgw_storybook_fixture_only");
},
};
export const Issuing: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
await issue(canvasElement);
await expect(
await within(canvasElement).findByRole("button", { name: "Issuing…" }),
).toBeDisabled();
},
};
export const IssueFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await issue(canvasElement);
await within(document.body).findByText("Token was not issued");
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "mutation-permission" },
play: async ({ canvasElement }) => {
await issue(canvasElement);
await within(document.body).findByText(
"You do not have permission to perform this action.",
);
},
};
export const RevokeToken: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: /Token history/ }),
);
await userEvent.click(await c.findByRole("button", { name: "Revoke" }));
await userEvent.type(
c.getByPlaceholderText("Preview client"),
"Preview client",
{ delay: 15 },
);
await userEvent.click(c.getByRole("button", { name: "Revoke token" }));
await within(document.body).findByText("Token revoked");
},
};
@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { GoalDetail } from "@/pages/GoalDetail";
import { expect, userEvent, within } from "storybook/test";
import {
storybookGoals,
storybookProjects,
} from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function Goal() {
return (
<Routes>
<Route path="/PAP/goals/:goalId" element={<GoalDetail />} />
</Routes>
);
}
const goal = {
...storybookGoals[0],
id: "goal-gap",
title: "Qualify the release",
};
const meta = {
id: "feature-goal-alignment",
title:
"Projects and execution/Goals and work alignment/Aligned work and navigation",
component: Goal,
parameters: { layout: "padded", initialEntries: ["/PAP/goals/goal-gap"] },
beforeEach: ({ parameters }) =>
prepareFixtureApi(
[
{ path: "/api/goals/goal-gap", read: () => goal },
{
path: "/api/companies/company-storybook/goals",
read: () =>
parameters.fixtureState === "empty"
? [goal]
: [
goal,
{
...goal,
id: "child-goal-gap",
title: "Verify provider access",
parentId: goal.id,
},
],
},
{
path: "/api/companies/company-storybook/projects",
read: () =>
parameters.fixtureState === "empty"
? []
: [
{
...storybookProjects[0],
name: "Release workspace",
goalId: goal.id,
goalIds: [goal.id],
goals: [{ id: goal.id, title: goal.title }],
},
],
},
],
parameters.fixtureState as FixtureState,
).cleanup,
} satisfies Meta<typeof Goal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const GoalAndChildren: Story = {
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("link", {
name: /Verify provider access/,
}),
).toHaveAttribute("href", "/PAP/goals/child-goal-gap");
},
};
export const AlignedProject: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("tab", { name: /Projects/ }));
await c.findByText("Release workspace");
},
};
export const EmptyRelationships: Story = {
parameters: { fixtureState: "empty" },
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { Inbox } from "@/pages/Inbox";
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { storybookIssues } from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function InboxHost(props: NonNullable<ComponentProps<typeof Inbox>>) {
return <Inbox {...props} />;
}
const meta = {
id: "feature-inbox-views",
title:
"Tasks and conversations/Inbox, decisions, and search/Personal task views",
component: InboxHost,
args: { tab: "mine" },
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
const api = prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/issues",
read: () =>
parameters.fixtureState === "empty" ? [] : storybookIssues,
},
{
path: /^\/api\/companies\/company-storybook\/(approvals|join-requests|heartbeat-runs)$/,
read: () => [],
},
],
parameters.fixtureState as FixtureState,
);
const flags = prepareFixtureApi([
{
path: "/api/instance/settings/experimental",
read: () => ({
...instanceExperimentalSettingsSchema.parse({}),
enableStreamlinedUi: true,
enableCombinedInboxTasks: true,
}),
},
]);
return () => {
flags.cleanup();
api.cleanup();
};
},
} satisfies Meta<typeof InboxHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Mine: Story = { args: { tab: "mine" } };
export const Recent: Story = { args: { tab: "recent" } };
export const Unread: Story = { args: { tab: "unread" } };
export const Blocked: Story = { args: { tab: "blocked" } };
export const Empty: Story = {
args: { tab: "mine" },
parameters: { fixtureState: "empty" },
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LiveUpdatesProvider } from "@/context/LiveUpdatesProvider";
import { ToastViewport } from "@/components/ToastViewport";
import { Button } from "@/components/ui/button";
import { expect, userEvent, within, waitFor } from "storybook/test";
import {
installLiveEventFixture,
listenForFixtureSocket,
publishFixtureEvent,
} from "../../support/capability-gaps/live-events";
function Publish({ eligible = true }: { eligible?: boolean }) {
const [ready, setReady] = useState(false);
useEffect(() => listenForFixtureSocket(() => setReady(true)), []);
return (
<>
<Button
disabled={!ready}
onClick={() =>
publishFixtureEvent({
id: 1,
companyId: "company-storybook",
type: "activity.logged",
createdAt: new Date().toISOString(),
toastEligible: eligible,
payload: {
entityType: "issue",
entityId: "toast-task",
action: "issue.created",
actorType: "user",
actorId: "another-user",
actorName: "Release reviewer",
details: {
issueIdentifier: "PAP-42",
issueTitle: "Review the release",
},
},
})
}
>
Publish task update
</Button>
<ToastViewport />
</>
);
}
function TaskNotifications(props: { eligible?: boolean }) {
return (
<LiveUpdatesProvider>
<Publish {...props} />
</LiveUpdatesProvider>
);
}
const meta = {
id: "feature-personal-task-toasts",
title:
"Tasks and conversations/Inbox, decisions, and search/Personal task notifications",
component: TaskNotifications,
parameters: {
layout: "padded",
initialEntries: ["/PAP/dashboard"],
docs: {
description: {
component:
"Production socket consumer and toast viewport. Server eligibility metadata is simulated; persisted participation and mentions require multiplayer tests.",
},
},
},
beforeEach: installLiveEventFixture,
} satisfies Meta<typeof TaskNotifications>;
export default meta;
type Story = StoryObj<typeof meta>;
async function publish(canvasElement: HTMLElement) {
const button = await within(canvasElement).findByRole("button", {
name: "Publish task update",
});
await waitFor(() => expect(button).toBeEnabled());
await userEvent.click(button);
}
export const EligibleTask: Story = {
play: async ({ canvasElement }) => {
await publish(canvasElement);
await within(document.body).findByText(/Release reviewer created/);
},
};
export const UnrelatedTaskStaysQuiet: Story = {
args: { eligible: false },
play: async ({ canvasElement }) => {
await publish(canvasElement);
await expect(
within(document.body).queryByText(/Release reviewer created/),
).not.toBeInTheDocument();
},
};
export const VisibleTaskStaysQuiet: Story = {
parameters: { initialEntries: ["/PAP/issues/toast-task"] },
play: async ({ canvasElement }) => {
await publish(canvasElement);
await expect(
within(document.body).queryByText(/Release reviewer created/),
).not.toBeInTheDocument();
},
};
export const DuplicateDelivery: Story = {
play: async ({ canvasElement }) => {
await publish(canvasElement);
await within(document.body).findByText(/Release reviewer created/);
await publish(canvasElement);
await expect(
within(document.body).getAllByText(/Release reviewer created/),
).toHaveLength(1);
},
};
@@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { instanceGeneralSettingsSchema } from "@paperclipai/shared";
import { InstanceGeneralSettings } from "@/pages/InstanceGeneralSettings";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
let settings = instanceGeneralSettingsSchema.parse({});
const meta = {
id: "feature-backup-retention",
title: "Oversight and operation/Instance operations/Backup retention",
component: InstanceGeneralSettings,
parameters: {
layout: "padded",
docs: {
description: {
component:
"Shipped general settings. Retention changes persist within this fixture; database backup and restoration require operator verification.",
},
},
},
beforeEach: ({ parameters }) => {
settings = instanceGeneralSettingsSchema.parse({});
const fixture = prepareFixtureApi(
[
{
path: "/api/instance/settings/general",
read: ({ method, body }) => {
if (method === "PATCH")
settings = instanceGeneralSettingsSchema.parse({
...settings,
...body,
});
return settings;
},
},
],
parameters.fixtureState as FixtureState,
);
return fixture.cleanup;
},
} satisfies Meta<typeof InstanceGeneralSettings>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CurrentPolicy: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("heading", {
name: "Backup retention",
});
},
};
export const Loading: Story = {
parameters: { fixtureState: "loading" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Loading general settings...");
},
};
export const LoadFailed: Story = {
parameters: { fixtureState: "failure" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"The request failed. Please try again.",
);
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"You do not have permission to perform this action.",
);
},
};
export const SaveRetention: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const button = await c.findByRole("button", { name: "14 days" });
await userEvent.click(button);
await waitFor(() => expect(settings.backupRetention.dailyDays).toBe(14));
await waitFor(() => expect(button).toBeEnabled());
await expect(button).toHaveClass("bg-accent");
},
};
export const Saving: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const button = await c.findByRole("button", { name: "14 days" });
await userEvent.click(button);
await expect(button).toBeDisabled();
},
};
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { Sidebar } from "@/components/Sidebar";
import { expect, userEvent, within, waitFor } from "storybook/test";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function SidebarPreferences() {
return (
<PluginLauncherProvider>
<Sidebar />
</PluginLauncherProvider>
);
}
const meta = {
id: "feature-sidebar-preferences",
title:
"Oversight and operation/Navigation, profile, and announcements/Sidebar preferences",
component: SidebarPreferences,
parameters: {
layout: "padded",
docs: {
description: {
component:
"Production sidebar with isolated saved order responses. Section collapse is session-local; recents are browser-local. No server persistence is claimed.",
},
},
},
beforeEach: ({ parameters }) =>
prepareFixtureApi(
[
{
path: "/api/sidebar-preferences/me",
read: () => ({ orderedIds: ["company-storybook"], updatedAt: null }),
},
{
path: "/api/companies/company-storybook/sidebar-preferences/me",
read: () => ({ orderedIds: [], updatedAt: null }),
},
],
parameters.fixtureState as FixtureState,
).cleanup,
} satisfies Meta<typeof SidebarPreferences>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SavedOrder: Story = {};
export const LoadingOrder: Story = { parameters: { fixtureState: "loading" } };
export const OrderReadFailed: Story = {
parameters: { fixtureState: "failure" },
};
export const OrderPermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const CollapseWork: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: /Work/ }));
await waitFor(() =>
expect(
c.queryByRole("link", { name: "Dashboard" }),
).not.toBeInTheDocument(),
);
},
};
@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import {
OnboardingWizard,
ONBOARDING_STORAGE_KEY,
} from "@/components/OnboardingWizard";
import { useDialog } from "@/context/DialogContext";
import { useCompanyListQuery } from "@/api/companies-query";
import { Button } from "@/components/ui/button";
import {
seedOnboardingDraft,
clearOnboardingDraft,
} from "../../fixtures/onboardingDraft";
function Resume() {
const companies = useCompanyListQuery();
const { openOnboarding } = useDialog();
const [generation, setGeneration] = useState(0);
useEffect(() => {
if (companies.isSuccess)
openOnboarding({ initialStep: 3, companyId: "company-storybook" });
}, [companies.isSuccess, openOnboarding]);
return (
<>
{companies.isSuccess ? <OnboardingWizard key={generation} /> : null}
<Button
className="fixed bottom-4 right-4 z-50"
onClick={() => {
setGeneration((n) => n + 1);
openOnboarding({ initialStep: 3, companyId: "company-storybook" });
}}
>
Reopen saved draft
</Button>
</>
);
}
const meta = {
id: "feature-onboarding-resume",
title:
"Getting started and access/Onboarding and first work/Resume a saved draft",
component: Resume,
parameters: { layout: "fullscreen" },
beforeEach: ({ parameters }) => {
seedOnboardingDraft();
if (parameters.invalid)
localStorage.setItem(ONBOARDING_STORAGE_KEY, "invalid JSON");
if (parameters.otherCompany)
localStorage.setItem(
ONBOARDING_STORAGE_KEY,
JSON.stringify({
step: 3,
createdCompanyId: "unavailable-company",
agentName: "Another account's draft",
}),
);
return clearOnboardingDraft;
},
} satisfies Meta<typeof Resume>;
export default meta;
type Story = StoryObj<typeof meta>;
export const SavedAgentName: Story = {
play: async () => {
await waitFor(
() =>
expect(
within(document.body).getByDisplayValue("Darnold"),
).toBeInTheDocument(),
{ timeout: 10000 },
);
},
};
export const DraftAfterRemount: Story = {
play: async ({ canvasElement }) => {
const body = within(document.body);
const input = await body.findByDisplayValue(
"Darnold",
{},
{ timeout: 10000 },
);
await userEvent.clear(input);
await userEvent.click(input);
await userEvent.paste("Release reviewer");
await waitFor(() =>
expect(
JSON.parse(localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}")
.agentName,
).toBe("Release reviewer"),
);
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Reopen saved draft" }),
);
await body.findByDisplayValue("Release reviewer");
},
};
export const MalformedDraft: Story = { parameters: { invalid: true } };
export const UnavailableCompanyDraft: Story = {
parameters: { otherCompany: true },
play: async () => {
const body = within(document.body);
await expect(await body.findByTestId("onboarding-wizard")).toBeVisible();
const name = await body.findByLabelText("Agent name");
await waitFor(() => expect(name).toBeVisible(), { timeout: 10000 });
await expect(name).toHaveValue("");
await waitFor(() =>
expect(
body.queryByDisplayValue("Another account's draft"),
).not.toBeInTheDocument(),
);
},
};
@@ -0,0 +1,40 @@
import { within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { Learnings } from "@/pages/Pipelines";
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
import type { FixtureState } from "../../support/capability-gaps/api";
function Host() {
return <Learnings />;
}
const meta = {
id: "feature-pipeline-learnings",
title:
"Automation and structured work/Pipelines, review queues, and learnings/Learnings",
component: Host,
parameters: {
layout: "padded",
initialEntries: ["/PAP/learnings"],
docs: {
description: {
component:
"Shipped Learnings with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
},
},
},
beforeEach: ({ parameters }) =>
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
} satisfies Meta<typeof Host>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(/Release reviewer approved/);
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,44 @@
import { within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { Pipelines } from "@/pages/Pipelines";
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
import type { FixtureState } from "../../support/capability-gaps/api";
function Host() {
return (
<Routes>
<Route path="/PAP/pipelines/:pipelineId" element={<Pipelines />} />
</Routes>
);
}
const meta = {
id: "feature-pipeline-board",
title:
"Automation and structured work/Pipelines, review queues, and learnings/Items",
component: Host,
parameters: {
layout: "padded",
initialEntries: ["/PAP/pipelines/pipeline-gap"],
docs: {
description: {
component:
"Shipped Pipelines with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
},
},
},
beforeEach: ({ parameters }) =>
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
} satisfies Meta<typeof Host>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findAllByText("Ship the release notes");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { Routes, Route } from "@/lib/router";
import { ReviewQueue } from "@/pages/Pipelines";
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
import type { FixtureState } from "../../support/capability-gaps/api";
function Host() {
return <ReviewQueue />;
}
const meta = {
id: "feature-pipeline-review",
title:
"Automation and structured work/Pipelines, review queues, and learnings/Review queue",
component: Host,
parameters: {
layout: "padded",
initialEntries: ["/PAP/review-queue"],
docs: {
description: {
component:
"Shipped ReviewQueue with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
},
},
},
beforeEach: ({ parameters }) =>
installPipelineFixture(parameters.fixtureState as FixtureState).cleanup,
} satisfies Meta<typeof Host>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findAllByText("Ship the release notes");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const Approved: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await c.findByText("Ship the release notes");
await userEvent.click(c.getByRole("button", { name: /^Approve$/ }));
await waitFor(() =>
expect(c.queryByText("Ship the release notes")).not.toBeInTheDocument(),
);
},
};
export const ApprovalFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await c.findByText("Ship the release notes");
await userEvent.click(c.getByRole("button", { name: /^Approve$/ }));
await c.findByText("Needs attention");
await c.findByText("Ship the release notes");
},
};
@@ -0,0 +1,54 @@
import { within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { PipelineSettings } from "@/pages/PipelineSettings";
import { installPipelineFixture } from "../../support/capability-gaps/pipelines";
import type { FixtureState } from "../../support/capability-gaps/api";
function Host() {
return (
<Routes>
<Route
path="/PAP/pipelines/:pipelineId/settings"
element={<PipelineSettings />}
/>
</Routes>
);
}
const meta = {
id: "feature-pipeline-stages",
title:
"Automation and structured work/Pipelines, review queues, and learnings/Stage settings",
component: Host,
parameters: {
layout: "padded",
initialEntries: ["/PAP/pipelines/pipeline-gap/settings"],
docs: {
description: {
component:
"Shipped PipelineSettings with isolated API receipts. Guards, durable transitions, automation execution and learning provenance require production tests.",
},
},
},
beforeEach: ({ parameters }) =>
installPipelineFixture(parameters.fixtureState as FixtureState, {
emptyStages: parameters.fixtureState === "empty",
}).cleanup,
} satisfies Meta<typeof Host>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByDisplayValue("Release review");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = {
parameters: { fixtureState: "empty" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("No stages configured.");
},
};
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
@@ -0,0 +1,75 @@
import { PluginManager } from "@/pages/PluginManager";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { PluginSettings } from "@/pages/PluginSettings";
import { expect, userEvent, within } from "storybook/test";
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
import type { FixtureState } from "../../support/capability-gaps/api";
function Settings() {
return (
<Routes>
<Route
path="/PAP/company/settings/instance/plugins"
element={<PluginManager />}
/>
<Route
path="/PAP/plugins/:pluginId/settings"
element={<PluginSettings />}
/>
</Routes>
);
}
const meta = {
id: "feature-plugin-configuration",
title: "Apps, channels, and extensions/Plugins/Configuration",
component: Settings,
parameters: {
layout: "padded",
initialEntries: ["/PAP/plugins/plugin-gap/settings"],
},
beforeEach: ({ parameters }) =>
installPluginFixtures(parameters.fixtureState as FixtureState),
} satisfies Meta<typeof Settings>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Configured: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Unavailable: Story = { parameters: { fixtureState: "failure" } };
export const SaveConfiguration: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const input = await c.findByDisplayValue("Release preview");
await userEvent.clear(input);
await userEvent.type(input, "Reviewed release", { delay: 15 });
await userEvent.click(
c.getByRole("button", { name: "Save Configuration" }),
);
await c.findByText("Configuration saved.");
await expect(input).toHaveValue("Reviewed release");
},
};
export const SaveFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const input = await c.findByDisplayValue("Release preview");
await userEvent.clear(input);
await userEvent.type(input, "Reviewed release", { delay: 15 });
await userEvent.click(
c.getByRole("button", { name: "Save Configuration" }),
);
await c.findByText("The request failed. Please try again.");
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "mutation-permission" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const input = await c.findByDisplayValue("Release preview");
await userEvent.type(input, " updated", { delay: 15 });
await userEvent.click(
c.getByRole("button", { name: "Save Configuration" }),
);
await c.findByText("You do not have permission to perform this action.");
},
};
@@ -0,0 +1,105 @@
import { PluginSettings } from "@/pages/PluginSettings";
import { PluginManager } from "@/pages/PluginManager";
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { PluginPage } from "@/pages/PluginPage";
import { registerPluginReactComponent } from "@/plugins/slots";
import { within } from "storybook/test";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function Contribution() {
return (
<Routes>
<Route
path="/PAP/company/settings/instance/plugins/:pluginId"
element={<PluginSettings />}
/>
<Route
path="/PAP/company/settings/instance/plugins"
element={<PluginManager />}
/>
<Route path="/PAP/plugins/:pluginId" element={<PluginPage />} />
</Routes>
);
}
function FixturePluginContent() {
return <p>Isolated plugin contribution</p>;
}
const meta = {
id: "feature-plugin-contributions",
title: "Apps, channels, and extensions/Plugins/Contributed routes",
component: Contribution,
parameters: {
layout: "padded",
initialEntries: ["/PAP/plugins/contribution-gap"],
},
beforeEach: ({ parameters }) => {
registerPluginReactComponent(
"storybook.contribution",
"FixturePluginContent",
FixturePluginContent,
);
const baseCleanup = installPluginFixtures(
"ready",
false,
"contribution-gap",
);
const fixture = prepareFixtureApi(
[
{
path: "/api/plugins/ui-contributions",
read: () =>
parameters.empty
? []
: [
{
pluginId: "contribution-gap",
pluginKey: "storybook.contribution",
displayName: "Preview contribution",
version: "1.0.0",
uiEntryFile: "fixture.js",
slots: [
{
type: "page",
id: "fixture-page",
displayName: "Preview",
exportName: "FixturePluginContent",
routePath: "fixture",
},
],
launchers: [],
},
],
},
],
parameters.fixtureState as FixtureState,
);
return () => {
fixture.cleanup();
baseCleanup();
};
},
} satisfies Meta<typeof Contribution>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MountedContribution: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("Isolated plugin contribution");
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const MissingContribution: Story = { parameters: { empty: true } };
export const DiscoveryFailure: Story = {
parameters: {
fixtureState: "failure",
docs: {
description: {
story:
"Current production PluginPage retains its Loading state after contribution discovery fails. This exposes a product error-state gap; it is not a passing failure UX.",
},
},
},
};
@@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { PluginManager } from "@/pages/PluginManager";
import { ToastViewport } from "@/components/ToastViewport";
import { installPluginFixtures } from "../../support/capability-gaps/plugins";
import type { FixtureState } from "../../support/capability-gaps/api";
function Plugins() {
return (
<>
<PluginManager />
<ToastViewport />
</>
);
}
const meta = {
id: "feature-plugin-management",
title: "Apps, channels, and extensions/Plugins/Installation and lifecycle",
component: Plugins,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) =>
installPluginFixtures(
parameters.fixtureState as FixtureState,
Boolean(parameters.empty),
),
} satisfies Meta<typeof Plugins>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Installed: Story = {};
export const Empty: Story = { parameters: { empty: true } };
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
async function install() {
const c = within(document.body);
await userEvent.click(
await c.findByRole("button", { name: "Install Plugin" }),
);
await userEvent.type(
await c.findByRole("textbox"),
"@paperclipai/storybook-fixture",
{ delay: 15 },
);
await userEvent.click(c.getByRole("button", { name: "Install" }));
}
export const InstallPackage: Story = {
parameters: { empty: true },
play: async () => {
await install();
await within(document.body).findByText("Plugin installed successfully");
await within(document.body).findByText("Preview plugin");
},
};
export const Installing: Story = {
parameters: { empty: true, fixtureState: "saving" },
play: async () => {
await install();
await expect(
await within(document.body).findByRole("button", {
name: "Installing...",
}),
).toBeDisabled();
},
};
export const InstallFailed: Story = {
parameters: { empty: true, fixtureState: "mutation-failure" },
play: async () => {
await install();
await within(document.body).findByText("Failed to install plugin");
},
};
export const DisablePlugin: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByTitle("Disable"));
await within(document.body).findByText("Plugin disabled");
await c.findByTitle("Enable");
},
};
export const UninstallPlugin: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(await within(canvasElement).findByTitle("Uninstall"));
const c = within(document.body);
await userEvent.click(c.getByRole("button", { name: "Uninstall" }));
await c.findByText("Plugin uninstalled successfully");
await expect(c.queryByText("Preview plugin")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,178 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { IssueRecoveryAction } from "@paperclipai/shared";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { IssueRecoveryActionCard } from "@/components/IssueRecoveryActionCard";
import {
DispositionRecoveryNotice,
DispositionRecoveryProvider,
} from "@/components/DispositionRecoveryNotice";
import { storybookAgents } from "../../fixtures/paperclipData";
const agent = storybookAgents[0];
const action: IssueRecoveryAction = {
id: "recovery-gap",
companyId: agent.companyId,
sourceIssueId: "task-gap",
recoveryIssueId: null,
kind: "deliberate_wait_without_target",
status: "active",
ownerType: "board",
ownerAgentId: null,
ownerUserId: null,
previousOwnerAgentId: agent.id,
returnOwnerAgentId: agent.id,
cause: "unchanged_source_state_exhausted",
fingerprint: "storybook-recovery-gap",
evidence: {
summary:
"Automatic recovery exhausted two attempts without a durable next step.",
},
nextAction: "Inspect the outcome before deciding whether to retry.",
wakePolicy: { type: "board_escalation" },
monitorPolicy: null,
attemptCount: 2,
maxAttempts: 2,
timeoutAt: null,
lastAttemptAt: "2026-10-12T12:00:00Z",
outcome: null,
resolutionNote: null,
resolvedAt: null,
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
function Recovery({
state = "ready",
}: {
state?: "ready" | "blocked" | "failure" | "saving" | "historical";
}) {
const [resolved, setResolved] = useState(false);
return (
<DispositionRecoveryProvider
value={{
issue: {
status: resolved ? "todo" : "blocked",
assigneeAgentId: agent.id,
executionRunId: null,
checkoutRunId: null,
executionState: null,
blockedBy: [],
activeRecoveryAction: state === "historical" ? null : action,
},
agentMap: new Map([[agent.id, agent]]),
unavailableReason:
state === "blocked"
? "You do not have permission to retry this task."
: null,
onRetry: async () => {
if (state === "saving") return new Promise<void>(() => {});
if (state === "failure")
throw new Error("The recovery decision could not be saved.");
setResolved(true);
},
}}
>
<DispositionRecoveryNotice
defaultExpanded
snapshot={{
kind: "disposition_repair_escalated",
actionId: action.id,
attemptCount: 2,
maxAttempts: 2,
reason: "unchanged_source_state_exhausted",
assigneeAgentId: agent.id,
}}
/>
</DispositionRecoveryProvider>
);
}
const meta = {
id: "feature-recovery-gap-states",
title: "Projects and execution/Recovery/Exhaustion and user decisions",
component: Recovery,
parameters: {
layout: "padded",
docs: {
description: {
component:
"Shipped recovery notice/card. Decisions alter local fixture state only. Bounded attempts, unknown-side-effect replay and real continuation require production tests.",
},
},
},
} satisfies Meta<typeof Recovery>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Exhausted: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("button", { name: "Retry agent" });
},
};
export const PermissionBlocked: Story = {
args: { state: "blocked" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await expect(
await c.findByRole("button", { name: "Retry agent" }),
).toBeDisabled();
await c.findByText("You do not have permission to retry this task.");
},
};
export const RetryFailed: Story = {
args: { state: "failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Retry agent" }),
);
await c.findByRole("alert");
},
};
export const RetryPending: Story = {
args: { state: "saving" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Retry agent" }),
);
const retry = await c.findByRole("button", { name: "Requesting retry…" });
await waitFor(() => expect(retry).toBeDisabled());
},
};
export const RetryAccepted: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Retry agent" }),
);
await c.findByRole("heading", { name: "Retry requested" });
},
};
export const NoActiveRecovery: Story = {
args: { state: "historical" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByRole("heading", {
name: "Agent needed attention",
});
},
};
export const UnknownOutcome: Story = {
render: () => (
<IssueRecoveryActionCard
action={{
...action,
kind: "active_run_watchdog",
cause: "external_execution_unknown",
evidence: {
summary:
"The external request may have completed. Inspect the provider before replaying it.",
},
nextAction:
"Inspect the external outcome; choose a recorded disposition.",
}}
/>
),
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"The external request may have completed. Inspect the provider before replaying it.",
);
},
};
@@ -0,0 +1,140 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { RoutineDetail } from "@/pages/RoutineDetail";
import { ToastViewport } from "@/components/ToastViewport";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { baseRoutine } from "../../fixtures/routineWebhooks";
import { storybookIssues } from "../../fixtures/paperclipData";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
let submitted: Record<string, unknown> | null = null;
function RunRoutine() {
return (
<>
<Routes>
<Route
path="/PAP/routines/:routineId/:section?"
element={<RoutineDetail />}
/>
</Routes>
<ToastViewport />
</>
);
}
const meta = {
id: "feature-routine-run-now",
title:
"Automation and structured work/Routines, schedules, and triggers/Run now",
component: RunRoutine,
parameters: {
layout: "padded",
initialEntries: ["/PAP/routines/routine-gap"],
},
beforeEach: ({ parameters }) => {
submitted = null;
let tasks: unknown[] = [];
return prepareFixtureApi(
[
{
path: "/api/routines/routine-gap",
read: () => ({
...baseRoutine,
id: "routine-gap",
title: "Review a release",
variables: [
{
name: "environment",
label: "Environment",
type: "text",
required: true,
defaultValue: "",
options: [],
},
],
}),
},
{ path: "/api/routines/routine-gap/runs", read: () => [] },
{ path: "/api/companies/company-storybook/issues", read: () => tasks },
{
path: "/api/routines/routine-gap/run",
method: "POST",
read: ({ body }) => {
submitted = body;
tasks = [
{
...storybookIssues[0],
id: "run-task-gap",
title: "Review a release in staging",
originKind: "routine_execution",
originId: "routine-gap",
},
];
return {
id: "routine-run-gap",
companyId: "company-storybook",
routineId: "routine-gap",
status: "enqueued",
linkedIssueId: "run-task-gap",
};
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof RunRoutine>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Overview: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
async function openRun() {
const c = within(document.body);
await userEvent.click(await c.findByRole("button", { name: "Run now" }));
return c;
}
export const RequiredInput: Story = {
play: async () => {
const c = await openRun();
await expect(c.getByRole("button", { name: "Run routine" })).toBeDisabled();
},
};
async function run() {
const c = await openRun();
await userEvent.type(
within(c.getByRole("dialog")).getByRole("textbox"),
"staging",
{ delay: 15 },
);
await userEvent.click(c.getByRole("button", { name: "Run routine" }));
}
export const Started: Story = {
play: async () => {
await run();
await within(document.body).findByText("Routine run started");
await waitFor(() =>
expect(submitted?.variables).toEqual({ environment: "staging" }),
);
},
};
export const Starting: Story = {
parameters: { fixtureState: "saving" },
play: async () => {
await run();
await expect(
await within(document.body).findByRole("button", { name: "Running..." }),
).toBeDisabled();
},
};
export const StartFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async () => {
await run();
await within(document.body).findByText("Routine run failed");
},
};
@@ -0,0 +1,143 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SecretProposalView } from "@paperclipai/shared";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { ProposalsTab } from "@/pages/secrets/ProposalsTab";
import { ToastViewport } from "@/components/ToastViewport";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
const proposal: SecretProposalView = {
id: "proposal-gap",
companyId: "company-storybook",
kind: "secret",
status: "pending",
justification: "The deploy task needs access to the GitHub API.",
proposedName: "dev/github/token",
proposedKey: "token",
proposedDescription: null,
valueFingerprintSha256: "abcdef0123456789abcdef",
valueLength: 40,
secretId: null,
secretName: null,
secretProposalId: null,
secretProposalName: null,
targetType: null,
target: null,
configPath: null,
proposedBy: { id: "agent-coder", name: "Codex Coder", icon: "code" },
originIssue: { id: "task-gap", key: "PAP-42", title: "Deploy preview" },
originRunId: "run-gap",
expiresAt: "2099-10-13T12:00:00Z",
createdAt: "2026-10-12T12:00:00Z",
resolvedByUserId: null,
resolvedAt: null,
resolutionReason: null,
createdSecretId: null,
appliedBindingConfigPath: null,
viewerCanApprove: true,
approveBlockReason: null,
};
let rows: SecretProposalView[] = [];
function ProposalReview() {
return (
<>
<ProposalsTab companyId="company-storybook" providerConfigs={[]} />
<ToastViewport />
</>
);
}
const meta = {
id: "feature-secret-proposal-review",
title: "Apps, channels, and extensions/Secrets and proposals/Proposal review",
component: ProposalReview,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
rows =
parameters.fixtureState === "empty"
? []
: [
{
...proposal,
viewerCanApprove: !parameters.blocked,
approveBlockReason: parameters.blocked
? "Only an authorized secret administrator can approve this proposal."
: null,
},
];
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/secret-proposals",
read: () => rows,
},
{
path: /^\/api\/companies\/company-storybook\/secret-proposals\/proposal-gap\/(approve|reject)$/,
method: "POST",
read: ({ url, body }) => {
const result = {
...rows[0],
status: url.pathname.endsWith("approve")
? ("approved" as const)
: ("rejected" as const),
resolutionReason: String(body.reason ?? ""),
resolvedAt: "2026-10-13T12:00:00Z",
};
rows = [];
return result;
},
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof ProposalReview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Pending: Story = {};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Failed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionBlocked: Story = {
parameters: { blocked: true },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("button", { name: "Approve" }),
).toBeDisabled();
},
};
export const RejectProposal: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Reject" }));
const body = within(document.body);
await userEvent.type(
await body.findByRole("textbox"),
"The task can use the existing managed connection.",
{ delay: 15 },
);
await userEvent.click(
within(body.getByRole("dialog")).getByRole("button", { name: "Reject" }),
);
await waitFor(() => expect(rows).toHaveLength(0));
await body.findByText("Proposal rejected");
},
};
export const RejectFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Reject" }),
);
const body = within(document.body);
await userEvent.type(
await body.findByRole("textbox"),
"Use the existing connection.",
{ delay: 15 },
);
await userEvent.click(
within(body.getByRole("dialog")).getByRole("button", { name: "Reject" }),
);
await body.findByText("The request failed. Please try again.");
},
};
@@ -0,0 +1,177 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { Routes, Route } from "@/lib/router";
import { SkillStudio } from "@/pages/SkillStudio";
import { makeSkill } from "../../support/capability-gaps/skills";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function Studio() {
return (
<Routes>
<Route path="/PAP/skills/studio/:skillId" element={<SkillStudio />} />
</Routes>
);
}
const meta = {
id: "feature-skill-test-runs",
title: "Agents and reusable capabilities/Skills and Skill Studio/Test runs",
component: Studio,
parameters: {
layout: "fullscreen",
initialEntries: ["/PAP/skills/studio/skill-gap"],
},
beforeEach: ({ parameters }) => {
const skill = makeSkill();
const run = {
id: "run-gap",
companyId: skill.companyId,
skillId: skill.id,
inputId: "input-gap",
inputSnapshot: "Review this release.",
skillVersionId: "version-gap",
agentId: "agent-codex",
agentConfigSnapshot: {},
issueId: "task-gap",
templateId: null,
templateName: null,
templateBody: null,
renderedTemplateBody: null,
harnessIssueDescription: "Review this release.",
status: parameters.runStatus ?? "succeeded",
outputDocumentKey: "result",
outputSnapshot:
parameters.runStatus === "running" || parameters.runStatus === "queued"
? null
: "The release checks passed.",
error:
parameters.runStatus === "failed"
? "The provider could not complete the test."
: null,
deletedAt: null,
supersededAt: null,
harnessIssueExpiresAt: null,
harnessIssueDeletedAt: null,
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:01:00Z",
cost: {
costCents: 2,
inputTokens: 100,
cachedInputTokens: 0,
outputTokens: 50,
},
taskExpired: false,
};
let runs = parameters.fixtureState === "empty" ? [] : [run];
const detail = () => ({
...run,
skillVersion: {
id: "version-gap",
companyId: skill.companyId,
companySkillId: skill.id,
revisionNumber: 1,
label: "Release check",
releaseId: null,
releaseName: null,
releasedAt: null,
fileInventory: [],
authorAgentId: null,
authorUserId: "user-storybook",
createdAt: run.createdAt,
},
outputBody: run.outputSnapshot,
harnessContent: {
available: false,
unavailableReason: "expired",
documents: [],
attachments: [],
workProducts: [],
},
harnessIssue: null,
documents: [],
interactions: [],
artifacts: [],
});
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/skills",
read: () => [skill],
},
{
path: "/api/companies/company-storybook/skills/skill-gap",
read: () => skill,
},
{
path: "/api/companies/company-storybook/skills/skill-gap/files",
read: () => ({
skillId: skill.id,
path: "SKILL.md",
content: skill.markdown,
language: "markdown",
editable: true,
}),
},
{
path: "/api/companies/company-storybook/skills/skill-gap/test-inputs",
read: () =>
parameters.fixtureState === "empty"
? []
: [
{
id: "input-gap",
companyId: skill.companyId,
skillId: skill.id,
name: "Release review",
content: "Review this release.",
createdBy: null,
deletedAt: null,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
},
],
},
{
path: "/api/companies/company-storybook/skill-test-run-templates",
read: () => [],
},
{
path: "/api/companies/company-storybook/skills/skill-gap/test-runs",
read: ({ method }) => {
if (method === "POST") {
const queued = { ...run, id: "run-queued", status: "queued" };
runs = [queued, ...runs];
return queued;
}
return runs;
},
},
{
path: "/api/companies/company-storybook/skills/skill-gap/test-runs/run-gap",
read: detail,
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof Studio>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CompletedTest: Story = {};
export const Queued: Story = { parameters: { runStatus: "queued" } };
export const Running: Story = { parameters: { runStatus: "running" } };
export const TestFailed: Story = { parameters: { runStatus: "failed" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const ResultDetails: Story = {
parameters: { initialEntries: ["/PAP/skills/studio/skill-gap?run=run-gap"] },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText("The release checks passed.");
},
};
@@ -0,0 +1,244 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Routes, Route } from "@/lib/router";
import { StatusCards } from "@/pages/StatusCards";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { makeStatusCard } from "../../support/capability-gaps/status-cards";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function StatusCardHost() {
return (
<Routes>
<Route path="/status/:cardId?" element={<StatusCards />} />
</Routes>
);
}
const meta = {
id: "feature-status-card-states",
title:
"Automation and structured work/Status cards/Authoring, updates and archive",
component: StatusCardHost,
parameters: { layout: "padded", initialEntries: ["/status"] },
beforeEach: ({ parameters }) => {
let card = makeStatusCard({
state: parameters.cardState ?? "active",
generatingIssueId:
parameters.cardState === "compiling" ? "summary-task-gap" : null,
...(parameters.cardState === "compiling"
? { queryCompiledAt: null, lastGeneratedAt: null }
: {}),
failureReason:
parameters.cardState === "error"
? "The summarizer could not finish this update."
: null,
summaryBody:
parameters.cardState === "compiling"
? null
: "Build and tests passed. Provider qualification remains pending.",
pendingChangeCount: parameters.stale ? 3 : 0,
archivedAt: parameters.archived ? "2026-10-12T12:00:00Z" : null,
});
let rows = parameters.fixtureState === "empty" ? [] : [card];
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/status-cards",
read: ({ method, url, body }) => {
if (method === "POST") {
card = makeStatusCard({
title: "New review card",
generatingIssueId: "summary-task-gap",
queryCompiledAt: null,
state: "compiling",
interestPrompt: String(body.interestPrompt),
summaryBody: null,
lastGeneratedAt: null,
});
rows = [card];
return card;
}
return rows.filter(
(row) =>
Boolean(row.archivedAt) ===
(url.searchParams.get("archived") === "true"),
);
},
},
{
path: "/api/status-cards/card-gap",
read: ({ method, body }) => {
if (method === "PATCH") {
card = {
...card,
archivedAt: body.archived ? "2026-10-13T12:00:00Z" : null,
pendingChangeCount: body.archived ? 0 : 1,
};
rows = [card];
}
return card;
},
},
{
path: /^\/api\/status-cards\/card-gap\/(refresh|recompile)$/,
method: "POST",
read: () => {
card = {
...card,
pendingChangeCount: 0,
summaryBody: "All qualification checks passed. Ready for review.",
lastGeneratedAt: "2026-10-13T12:00:00Z",
};
rows = [card];
return card;
},
},
{ path: "/api/status-cards/card-gap/updates", read: () => [] },
{
path: "/api/status-cards/card-gap/summary-revisions",
read: () => [],
},
{
path: "/api/status-cards/card-gap/dry-run",
read: () => ({
cardId: card.id,
queryVersion: 1,
queries: [],
mentionedIssues: [],
}),
},
],
parameters.fixtureState as FixtureState,
).cleanup;
},
} satisfies Meta<typeof StatusCardHost>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CurrentSummary: Story = {
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"Build and tests passed. Provider qualification remains pending.",
);
},
};
export const Loading: Story = { parameters: { fixtureState: "loading" } };
export const Empty: Story = { parameters: { fixtureState: "empty" } };
export const LoadFailed: Story = { parameters: { fixtureState: "failure" } };
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
};
export const SettingUp: Story = { parameters: { cardState: "compiling" } };
export const GenerationFailed: Story = { parameters: { cardState: "error" } };
export const StaleSummary: Story = { parameters: { stale: true } };
async function create() {
const c = within(document.body);
const button = (await c.findAllByRole("button", { name: "New card" }))[0];
await waitFor(() => expect(button).toBeEnabled());
await userEvent.click(button);
await userEvent.type(
await c.findByRole("textbox", {
name: "What do you want to keep an eye on?",
}),
"Watch the release checks and summarize pending qualification.",
{ delay: 15 },
);
const createButton = c.getByRole("button", { name: "Create card" });
await waitFor(() => expect(createButton).toBeEnabled());
await userEvent.click(createButton);
}
export const Created: Story = {
parameters: { fixtureState: "empty" },
play: async () => {
await create();
await within(document.body).findByText("New review card");
},
};
export const CreateFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async () => {
await create();
await within(document.body).findByText(
"The request failed. Please try again.",
);
},
};
export const Refreshed: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Refresh card" }),
);
await c.findByText("All qualification checks passed. Ready for review.");
},
};
export const RefreshFailed: Story = {
parameters: { fixtureState: "mutation-failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Refresh card" }),
);
await c.findByText("The request failed. Please try again.");
},
};
export const Refreshing: Story = {
parameters: { fixtureState: "saving" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Refresh card" }),
);
await expect(
c.getByRole("button", { name: "Refresh card" }),
).toBeDisabled();
},
};
async function action(canvasElement: HTMLElement, name: string) {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Card actions" }),
);
await userEvent.click(
await within(document.body).findByRole("menuitem", { name }),
);
}
export const Inspection: Story = {
play: async ({ canvasElement }) => {
await action(canvasElement, "Open detail");
await within(document.body).findByRole("dialog");
},
};
export const Archive: Story = {
play: async ({ canvasElement }) => {
await action(canvasElement, "Archive");
await waitFor(() =>
expect(
within(canvasElement).queryByText(
"Build and tests passed. Provider qualification remains pending.",
),
).not.toBeInTheDocument(),
);
},
};
export const Archived: Story = {
parameters: { archived: true },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Show archived (1)" }),
);
await c.findByRole("button", { name: "Restore" });
},
};
export const Restored: Story = {
parameters: { archived: true },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(
await c.findByRole("button", { name: "Show archived (1)" }),
);
await userEvent.click(c.getByRole("button", { name: "Restore" }));
await c.findByText(
"Build and tests passed. Provider qualification remains pending.",
);
},
};
@@ -0,0 +1,95 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { Button } from "@/components/ui/button";
import {
saveDraft,
loadDraft,
clearDraft,
saveDraftSubmission,
loadDraftSubmission,
} from "@/lib/composer-draft";
import { expect, userEvent, within, waitFor } from "storybook/test";
const first = "paperclip:storybook:gap-draft-a",
second = "paperclip:storybook:gap-draft-b";
function resetFixtureDraft(key: string) {
clearDraft(key, loadDraftSubmission(key)?.attemptId);
}
function Drafts({ fail = false }: { fail?: boolean }) {
const [task, setTask] = useState(first);
return (
<>
<Button onClick={() => setTask(task === first ? second : first)}>
Switch task
</Button>
<TaskChatComposer
key={task}
draftKey={task}
workMode="standard"
onAdd={async () => {
if (fail) throw new Error("The message could not be saved.");
}}
/>
</>
);
}
const meta = {
id: "feature-composer-draft-recovery",
title: "Tasks and conversations/Steering and queued messages/Draft recovery",
component: Drafts,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) => {
resetFixtureDraft(first);
resetFixtureDraft(second);
if (!parameters.empty)
saveDraft(first, "Review the release before publishing.");
saveDraft(second, "Check provider qualification.");
if (parameters.uncertain)
saveDraftSubmission(first, {
attemptId: "00000000-0000-4000-8000-000000000001",
reviewed: false,
});
return () => {
resetFixtureDraft(first);
resetFixtureDraft(second);
};
},
} satisfies Meta<typeof Drafts>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Restored: Story = {};
export const Empty: Story = { parameters: { empty: true } };
export const UncertainSubmission: Story = { parameters: { uncertain: true } };
export const SwitchAndRestore: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
const editor = await c.findByRole("textbox");
await userEvent.click(editor);
await userEvent.paste(" Additional evidence.");
await waitFor(
() => expect(loadDraft(first)).toContain("Additional evidence."),
{ timeout: 3000 },
);
await userEvent.click(c.getByRole("button", { name: "Switch task" }));
await c.findByText("Check provider qualification.");
await userEvent.click(c.getByRole("button", { name: "Switch task" }));
await c.findByText(/Additional evidence/);
},
};
export const SendFailedKeepsDraft: Story = {
args: { fail: true },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: /Send/ }));
await waitFor(() =>
expect(loadDraft(first)).toContain("Review the release"),
);
},
};
export const SentClearsDraft: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: /Send/ }));
await waitFor(() => expect(loadDraft(first)).toBe(""));
},
};
@@ -0,0 +1,173 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
import { expect, userEvent, within, waitFor } from "storybook/test";
import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
const initialQueue: IssueQueuedCommentQueue = {
issueId: "task-gap",
queueId: "response-gap",
state: "deferred",
targetRunId: "run-gap",
revision: "rev-1",
protocol: "paperclip_runner_v1",
steeringDisposition: "available",
entries: [
{
position: 0,
canEdit: false,
canDiscard: false,
source: {
kind: "interaction",
interactionId: "approval-gap",
interactionKind: "request_confirmation",
requiresFreshSession: true,
},
comment: {
id: "response-comment",
companyId: "company-storybook",
issueId: "task-gap",
authorType: "user",
authorAgentId: null,
authorUserId: "user-storybook",
body: "Accepted: Review the migration\n\nApproved revision v2.",
presentation: null,
metadata: null,
createdAt: new Date("2026-10-12T12:00:00Z"),
updatedAt: new Date("2026-10-12T12:00:00Z"),
},
},
],
};
function ResponseQueue({
state = "ready",
}: {
state?: "ready" | "empty" | "waiting" | "failure" | "saving" | "blocked";
}) {
const [queue, setQueue] = useState<IssueQueuedCommentQueue>({
...initialQueue,
queueId: state === "blocked" ? null : initialQueue.queueId,
entries: state === "empty" ? [] : initialQueue.entries,
executionWait:
state === "waiting"
? {
reason: "remote_cleanup",
message:
"Waiting for the previous environment to stop. Your message will start automatically.",
}
: undefined,
});
const deliver = async () => {
if (state === "failure")
throw new Error("The response could not be delivered.");
if (state === "saving") return new Promise<void>(() => {});
setQueue((current) => ({
...current,
entries: [],
executionWait: undefined,
revision: "rev-2",
}));
};
return (
<TaskChatQueuedMessages
queue={queue}
onEdit={() => {
throw new Error("Accepted responses cannot be edited.");
}}
onReorder={async () => {
throw new Error("Accepted responses cannot be reordered.");
}}
onSteer={deliver}
onInterrupt={deliver}
onDiscard={async () => {
throw new Error("Accepted responses cannot be discarded.");
}}
/>
);
}
const meta = {
id: "feature-durable-response-queue",
title:
"Tasks and conversations/Steering and queued messages/Accepted response delivery",
component: ResponseQueue,
parameters: {
layout: "padded",
docs: {
description: {
component:
"Production queue with an immutable accepted response and remote-cleanup wait. Delivery uses local state; it does not prove persisted queues or runtime continuation.",
},
},
},
} satisfies Meta<typeof ResponseQueue>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AcceptedResponse: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await c.findByText("Accepted: Review the migration");
await expect(
c.getByRole("button", { name: /Reorder queued message/ }),
).toBeDisabled();
},
};
export const WaitingForCleanup: Story = {
args: { state: "waiting" },
play: async ({ canvasElement }) => {
await within(canvasElement).findByText(
"Waiting for the previous environment to stop. Your message will start automatically.",
);
},
};
export const AdmissionUnavailable: Story = {
args: { state: "blocked" },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByRole("button", { name: "Interrupt" }),
).toBeDisabled();
},
};
export const Empty: Story = { args: { state: "empty" } };
export const DeliveryFailed: Story = {
args: { state: "failure" },
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Interrupt" }));
await waitFor(() =>
expect(c.getByText("Accepted: Review the migration")).toBeInTheDocument(),
);
await c.findByText("Couldn’t interrupt. Message is still queued.");
},
};
export const Delivering: Story = {
args: { state: "saving" },
parameters: {
docs: {
description: {
story:
"The shipped queue removes accepted responses optimistically while delivery remains pending; it has no separate visible progress indicator.",
},
},
},
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Interrupt" }),
);
await waitFor(() =>
expect(
within(canvasElement).queryByTestId("task-chat-queued-messages"),
).not.toBeInTheDocument(),
);
await expect(canvasElement).toBeEmptyDOMElement();
},
};
export const Delivered: Story = {
play: async ({ canvasElement }) => {
const c = within(canvasElement);
await userEvent.click(await c.findByRole("button", { name: "Interrupt" }));
await waitFor(() =>
expect(
c.queryByText("Accepted: Review the migration"),
).not.toBeInTheDocument(),
);
},
};
@@ -0,0 +1,127 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { ExecutionWorkspaceCloseDialog } from "@/components/ExecutionWorkspaceCloseDialog";
import { ToastViewport } from "@/components/ToastViewport";
import {
prepareFixtureApi,
type FixtureState,
} from "../../support/capability-gaps/api";
function CloseWorkspace() {
const [open, setOpen] = useState(true);
return (
<>
<ExecutionWorkspaceCloseDialog
workspaceId="workspace-gap"
workspaceName="Feature review checkout"
currentStatus="active"
open={open}
onOpenChange={setOpen}
/>
<ToastViewport />
</>
);
}
const meta = {
id: "feature-workspace-close",
title:
"Projects and execution/Workspaces, services, and files/Close readiness",
component: CloseWorkspace,
parameters: { layout: "padded" },
beforeEach: ({ parameters }) =>
prepareFixtureApi(
[
{
path: "/api/execution-workspaces/workspace-gap/close-readiness",
read: () => ({
workspaceId: "workspace-gap",
deliveryState: "unknown",
isDestructiveCloseAllowed: !parameters.blocked,
state: parameters.blocked ? "blocked" : "ready",
linkedIssues: [],
blockingReasons: parameters.blocked
? ["A task is still running in this workspace."]
: [],
warnings: [],
runtimeServices: [],
plannedActions: [],
isSharedWorkspace: false,
isProjectPrimaryWorkspace: false,
git: null,
}),
},
{
path: "/api/execution-workspaces/workspace-gap",
method: "PATCH",
read: ({ body }) => ({
id: "workspace-gap",
companyId: "company-storybook",
name: "Feature review checkout",
...body,
}),
},
],
parameters.fixtureState as FixtureState,
).cleanup,
} satisfies Meta<typeof CloseWorkspace>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ready: Story = {
play: async () => {
await within(document.body).findByText("Close is ready");
},
};
export const Loading: Story = {
parameters: { fixtureState: "loading" },
play: async () => {
await within(document.body).findByText(
"Checking whether this workspace is safe to close...",
);
},
};
export const FailedInspection: Story = {
parameters: { fixtureState: "failure" },
play: async () => {
await within(document.body).findByText(
"The request failed. Please try again.",
);
},
};
export const PermissionDenied: Story = {
parameters: { fixtureState: "permission" },
play: async () => {
await within(document.body).findByText(
"You do not have permission to perform this action.",
);
},
};
export const BlockedByRunningTask: Story = {
parameters: { blocked: true },
play: async () => {
const c = within(document.body);
await c.findByText("Close is blocked");
await expect(
c.getByRole("button", { name: "Close workspace" }),
).toBeDisabled();
},
};
export const Closing: Story = {
parameters: { fixtureState: "saving" },
play: async () => {
const c = within(document.body);
await c.findByText("Close is ready");
await userEvent.click(c.getByRole("button", { name: "Close workspace" }));
await expect(
c.getByRole("button", { name: "Close workspace" }),
).toBeDisabled();
},
};
export const Closed: Story = {
play: async () => {
const c = within(document.body);
await c.findByText("Close is ready");
await userEvent.click(c.getByRole("button", { name: "Close workspace" }));
await c.findByText("Workspace closed");
await expect(c.queryByRole("dialog")).not.toBeInTheDocument();
},
};
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import {
installFixtureApi,
prepareFixtureApi,
activatePreparedFixtures,
} from "./api";
let cleanup: (() => void) | undefined;
afterEach(() => {
cleanup?.();
cleanup = undefined;
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
});
describe("capability story API boundary", () => {
it("activates story routes after preview defaults and restores those defaults", async () => {
const preview = vi.fn(async () => Response.json({ source: "preview" }));
const fixture = prepareFixtureApi([
{ path: "/api/subject", read: () => ({ source: "story" }) },
]);
cleanup = fixture.cleanup;
vi.stubGlobal("fetch", preview);
activatePreparedFixtures();
expect(await (await window.fetch("/api/subject")).json()).toEqual({
source: "story",
});
expect(await (await window.fetch("/api/other")).json()).toEqual({
source: "preview",
});
fixture.cleanup();
cleanup = undefined;
expect(window.fetch).toBe(preview);
});
it("blocks an unregistered mutation without calling the outer fetch", async () => {
const outer = vi
.spyOn(window, "fetch")
.mockResolvedValue(Response.json({ unexpected: true }));
cleanup = installFixtureApi([]).cleanup;
const reply = await window.fetch("/api/companies/company-storybook", {
method: "PATCH",
body: "{}",
});
expect(reply.status).toBe(501);
expect(outer).not.toHaveBeenCalled();
});
it("parses Request bodies and records the mutation before a simulated denial", async () => {
const handler = vi.fn(() => ({ saved: true }));
const fixture = installFixtureApi(
[{ path: "/api/example", method: "POST", read: handler }],
"mutation-permission",
);
cleanup = fixture.cleanup;
const result = await window.fetch(
new Request(`${location.origin}/api/example`, {
method: "POST",
body: JSON.stringify({ value: 42 }),
}),
);
expect(result.status).toBe(403);
expect(fixture.requests[0].body).toEqual({ value: 42 });
expect(handler).not.toHaveBeenCalled();
});
it("keeps prerequisite flags available during a subject error and restores fetch and company selection", async () => {
const previous = window.fetch;
localStorage.setItem("paperclip.selectedCompanyId", "original-company");
const fixture = installFixtureApi(
[
{ path: "/api/flags", state: "ready", read: () => ({ enabled: true }) },
{ path: "/api/subject", read: () => [] },
],
"failure",
);
cleanup = fixture.cleanup;
expect(await (await window.fetch("/api/flags")).json()).toEqual({
enabled: true,
});
expect((await window.fetch("/api/subject")).status).toBe(500);
fixture.cleanup();
cleanup = undefined;
expect(window.fetch).toBe(previous);
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe(
"original-company",
);
});
});
+117
View File
@@ -0,0 +1,117 @@
export type FixtureState =
| "ready"
| "loading"
| "empty"
| "failure"
| "permission"
| "saving"
| "mutation-failure"
| "mutation-permission";
export interface FixtureRequest {
url: URL;
method: string;
body: Record<string, unknown>;
}
export interface FixtureRoute {
path: string | RegExp;
method?: string;
state?: FixtureState;
read: (
request: FixtureRequest,
) => unknown | Response | Promise<unknown | Response>;
}
const pendingFixtures: Array<() => void> = [];
export function activatePreparedFixtures() {
for (const activate of pendingFixtures.splice(0)) activate();
}
export function prepareFixtureApi(
routes: FixtureRoute[],
state: FixtureState = "ready",
) {
return installFixtureApi(routes, state, true);
}
export function installFixtureApi(
routes: FixtureRoute[],
state: FixtureState = "ready",
deferred = false,
) {
let previous = window.fetch;
const selectedCompany = localStorage.getItem("paperclip.selectedCompanyId");
localStorage.setItem("paperclip.selectedCompanyId", "company-storybook");
const requests: FixtureRequest[] = [];
const fixtureFetch: typeof fetch = async (input, init) => {
const request = input instanceof Request ? input : null;
const url = new URL(request?.url ?? String(input), location.origin);
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
const route = routes.find(
(candidate) =>
(typeof candidate.path === "string"
? candidate.path === url.pathname
: candidate.path.test(url.pathname)) &&
(!candidate.method || candidate.method === method),
);
if (!route) {
if (url.pathname.startsWith("/api/") && method !== "GET") {
return Response.json(
{ error: `No isolated fixture for ${method} ${url.pathname}` },
{ status: 501 },
);
}
return previous(input, init);
}
const routeState = route.state ?? state;
const rawBody =
init?.body ??
(request && method !== "GET" ? await request.clone().text() : null);
const body = rawBody
? (JSON.parse(String(rawBody)) as Record<string, unknown>)
: {};
const fixtureRequest = { url, method, body };
requests.push(fixtureRequest);
if (
(routeState === "loading" && method === "GET") ||
(routeState === "saving" && method !== "GET")
) {
return new Promise<Response>(() => {});
}
if (
routeState === "failure" ||
routeState === "permission" ||
(method !== "GET" &&
(routeState === "mutation-failure" ||
routeState === "mutation-permission"))
) {
const denied =
routeState === "permission" || routeState === "mutation-permission";
return Response.json(
{
error: denied
? "You do not have permission to perform this action."
: "The request failed. Please try again.",
},
{ status: denied ? 403 : 500 },
);
}
const result = await route.read(fixtureRequest);
return result instanceof Response ? result : Response.json(result);
};
let active = !deferred;
const activate = () => {
previous = window.fetch;
window.fetch = fixtureFetch;
active = true;
};
if (deferred) pendingFixtures.push(activate);
else window.fetch = fixtureFetch;
return {
requests,
cleanup: () => {
const pendingIndex = pendingFixtures.indexOf(activate);
if (pendingIndex >= 0) pendingFixtures.splice(pendingIndex, 1);
if (active) window.fetch = previous;
if (selectedCompany === null)
localStorage.removeItem("paperclip.selectedCompanyId");
else localStorage.setItem("paperclip.selectedCompanyId", selectedCompany);
},
};
}
@@ -0,0 +1,114 @@
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
import type { CaseSummary, CaseDetail, CaseEvent } from "@/api/cases";
import { prepareFixtureApi, type FixtureState } from "./api";
export const caseItem: CaseSummary = {
id: "case-gap",
companyId: "company-storybook",
projectId: null,
caseNumber: 42,
identifier: "PAP-C42",
caseType: "release",
key: "release-preview",
title: "Release readiness",
summary: "The release review record.",
status: "in_review",
fields: {
version: "0.3.1",
ready: false,
checks: { build: "passed", tests: "pending" },
task_identifiers: ["PAP-42"],
},
parentCaseId: null,
createdByAgentId: null,
createdByUserId: null,
completedAt: null,
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
export const childCase: CaseSummary = {
...caseItem,
id: "child-gap",
caseNumber: 43,
identifier: "PAP-C43",
title: "API qualification",
parentCaseId: caseItem.id,
status: "in_progress",
};
export const caseDetail: CaseDetail = {
...caseItem,
parent: null,
labels: [],
documents: [],
attachments: [],
issueLinks: [
{
id: "link-gap",
caseId: caseItem.id,
issueId: "task-gap",
role: "work",
createdAt: caseItem.createdAt,
issue: {
id: "task-gap",
identifier: "PAP-42",
title: "Qualify the release",
status: "in_progress",
},
},
],
};
const event: CaseEvent = {
id: "event-gap",
caseId: caseItem.id,
kind: "status_changed",
actorType: "agent",
actorAgentId: "agent-coder",
actorUserId: null,
runId: "run-gap",
payload: { from: "in_progress", to: "in_review" },
createdAt: caseItem.updatedAt,
actorAgentName: "Codex Coder",
issue: caseDetail.issueLinks[0].issue,
};
export function installCaseFixtures(state: FixtureState = "ready") {
return prepareFixtureApi(
[
{
path: "/api/instance/settings/experimental",
state: "ready",
read: () => ({
...instanceExperimentalSettingsSchema.parse({}),
enableCases: true,
}),
},
{
path: "/api/companies/company-storybook/cases",
read: ({ url }) =>
state === "empty"
? []
: url.searchParams.has("parent")
? [childCase]
: [caseItem, childCase],
},
{ path: /^\/api\/cases\/(PAP-C42|case-gap)$/, read: () => caseDetail },
{
path: /^\/api\/cases\/(PAP-C42|case-gap)\/events$/,
read: () => (state === "empty" ? [] : [event]),
},
{
path: "/api/issues/task-gap/cases",
read: () =>
state === "empty"
? []
: [
{
id: "reverse-link-gap",
role: "work",
createdAt: caseItem.createdAt,
case: caseItem,
},
],
},
],
state,
).cleanup;
}
@@ -0,0 +1,172 @@
import type { Environment, EnvironmentCapabilities } from "@paperclipai/shared";
import { prepareFixtureApi, type FixtureState } from "./api";
export const environment: Environment = {
id: "environment-gap",
name: "Release sandbox",
description: "Isolated release checks",
driver: "sandbox",
status: "active",
config: { provider: "daytona", reuseLease: true },
envVars: {},
metadata: null,
createdAt: new Date("2026-10-12"),
updatedAt: new Date("2026-10-12"),
};
const capabilities: EnvironmentCapabilities = {
adapters: [],
drivers: {
local: "supported",
ssh: "supported",
sandbox: "supported",
plugin: "unsupported",
},
sandboxProviders: {
fake: {
status: "unsupported",
supportsLoginPty: false,
supportsSavedProbe: false,
supportsUnsavedProbe: false,
supportsRunExecution: false,
supportsReusableLeases: false,
supportsInteractiveSetup: false,
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
},
daytona: {
status: "supported",
supportsLoginPty: true,
supportsSavedProbe: true,
supportsUnsavedProbe: true,
supportsRunExecution: true,
supportsReusableLeases: true,
supportsInteractiveSetup: true,
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
displayName: "Daytona",
},
},
};
export function installEnvironmentFixture(
state: FixtureState = "ready",
image: "none" | "captured" | "failed" | "loading" | "denied" = "none",
unsupported = false,
) {
let current = { ...environment };
const template = {
id: "template-gap",
environmentId: environment.id,
provider: "daytona",
templateKind: "snapshot",
templateRef: "fixture-template",
sourceTemplateRef: null,
sourceEnvironmentConfigFingerprint: "fixture-fingerprint",
status: "active",
createdByUserId: "user-storybook",
createdByAgentId: null,
capturedAt: "2026-10-12T12:00:00Z",
lastUsedAt: null,
supersededByTemplateId: null,
metadata: null,
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
return prepareFixtureApi(
[
{
path: "/api/instance/settings/experimental",
state: "ready",
read: () => ({
enableEnvironments: true,
enableManagedSandboxOnly: false,
}),
},
{
path: "/api/instance/settings",
state: "ready",
read: () => ({ defaultEnvironmentId: environment.id }),
},
{
path: "/api/companies/company-storybook/environments",
read: () => (state === "empty" ? [] : [current]),
},
{
path: "/api/companies/company-storybook/environments/capabilities",
read: () =>
unsupported
? {
...capabilities,
sandboxProviders: {
fake: {
status: "unsupported",
supportsLoginPty: false,
supportsSavedProbe: false,
supportsUnsavedProbe: false,
supportsRunExecution: false,
supportsReusableLeases: false,
supportsInteractiveSetup: false,
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
},
daytona: {
...capabilities.sandboxProviders.daytona,
status: "unsupported",
supportsSavedProbe: false,
supportsTemplateCapture: false,
},
},
}
: capabilities,
},
{
path: "/api/environments/environment-gap/secret-refs",
read: () => ({ refs: [] }),
},
{
path: "/api/environments/environment-gap",
method: "PATCH",
read: ({ body }) => {
current = { ...current, ...body };
return current;
},
},
{
path: "/api/environments/environment-gap/probe",
method: "POST",
read: () => ({
ok: true,
driver: "sandbox",
summary: "Provider connection succeeded.",
details: {},
}),
},
{
path: "/api/environments/environment-gap/custom-image-template",
read: () =>
image === "loading"
? new Promise<Response>(() => {})
: image === "denied"
? Response.json(
{
error: "You do not have permission to inspect this image.",
},
{ status: 403 },
)
: image === "failed"
? Response.json(
{ error: "Image metadata is unavailable." },
{ status: 503 },
)
: {
activeTemplate: image === "captured" ? template : null,
activeTemplateMatchesConfig: true,
activeSession: null,
latestSession: null,
},
},
],
state,
);
}
@@ -0,0 +1,53 @@
import type { LiveEvent } from "@paperclipai/shared";
const sockets: FixtureSocket[] = [];
const listeners = new Set<() => void>();
class FixtureSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = 0;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor() {
sockets.push(this);
queueMicrotask(() => {
if (this.readyState === 3) return;
this.readyState = 1;
this.onopen?.(new Event("open"));
for (const listener of listeners) listener();
});
}
close() {
this.readyState = 3;
}
send() {}
}
export function listenForFixtureSocket(listener: () => void) {
listeners.add(listener);
if (sockets.some((s) => s.readyState === 1)) listener();
return () => {
listeners.delete(listener);
};
}
export function publishFixtureEvent(event: LiveEvent) {
for (const socket of sockets)
if (socket.readyState === 1)
socket.onmessage?.(
new MessageEvent("message", { data: JSON.stringify(event) }),
);
}
export function installLiveEventFixture() {
const previous = window.WebSocket;
sockets.splice(0);
listeners.clear();
window.WebSocket = FixtureSocket as unknown as typeof WebSocket;
return () => {
for (const socket of sockets) socket.close();
sockets.splice(0);
listeners.clear();
window.WebSocket = previous;
};
}
@@ -0,0 +1,201 @@
import type {
PipelineDetail,
PipelineCase,
PipelineReviewCaseRow,
PipelineCompanyCaseEvent,
} from "@/api/pipelines";
import { prepareFixtureApi, type FixtureState } from "./api";
export const pipeline: PipelineDetail = {
id: "pipeline-gap",
companyId: "company-storybook",
key: "release",
name: "Release review",
description: "Check and approve a release.",
projectId: null,
enforceTransitions: true,
archivedAt: null,
stageCount: 3,
openCaseCount: 1,
createdAt: "2026-10-12",
updatedAt: "2026-10-12",
stages: [
{
id: "stage-work",
pipelineId: "pipeline-gap",
key: "work",
name: "Prepare",
kind: "working",
position: 0,
},
{
id: "stage-review",
pipelineId: "pipeline-gap",
key: "review",
name: "Review",
kind: "review",
position: 1,
config: {
approveToStageKey: "done",
requestChangesToStageKey: "work",
requireRequestChangesReason: true,
},
},
{
id: "stage-done",
pipelineId: "pipeline-gap",
key: "done",
name: "Done",
kind: "done",
position: 2,
},
],
transitions: [
{ fromStageId: "stage-work", toStageId: "stage-review" },
{ fromStageId: "stage-review", toStageId: "stage-done" },
],
};
export function installPipelineFixture(
state: FixtureState = "ready",
options: { emptyStages?: boolean } = {},
) {
let item: PipelineCase = {
id: "case-pipeline-gap",
companyId: "company-storybook",
pipelineId: pipeline.id,
stageId: "stage-review",
caseKey: "REL-1",
title: "Ship the release notes",
version: 1,
fields: { release: "2026.1013.0" },
createdAt: "2026-10-12",
updatedAt: "2026-10-12",
};
let review = true;
const detail = () => ({
case: item,
stage: pipeline.stages.find((s) => s.id === item.stageId),
pipeline,
allowedNextStages: [pipeline.stages[2]],
links: [],
blockers: [],
blocks: [],
childrenSummary: {
childCount: 0,
terminalChildCount: 0,
loadedChildren: 0,
},
});
const event: PipelineCompanyCaseEvent = {
id: "event-gap",
companyId: "company-storybook",
caseId: item.id,
type: "review_decided",
actorType: "user",
actorUserId: "user-storybook",
fromStageId: "stage-review",
toStageId: "stage-done",
fromStage: pipeline.stages[1],
toStage: pipeline.stages[2],
payload: {
decision: "approve",
actorName: "Release reviewer",
reason: "All release checks passed.",
},
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
case: {
id: item.id,
caseKey: item.caseKey ?? null,
title: item.title,
terminalKind: null,
},
pipeline: { id: pipeline.id, key: pipeline.key, name: pipeline.name },
};
const reviewRow = (): PipelineReviewCaseRow => ({
case: item,
pipeline: { id: pipeline.id, key: pipeline.key, name: pipeline.name },
stage: pipeline.stages[1],
reviewConfig: {
approveToStageKey: "done",
requestChangesToStageKey: "work",
requireRequestChangesReason: true,
},
});
return prepareFixtureApi(
[
{
path: "/api/companies/company-storybook/pipelines",
read: () => (state === "empty" ? [] : [pipeline]),
},
{
path: "/api/pipelines/pipeline-gap",
read: () =>
options.emptyStages
? { ...pipeline, stages: [], stageCount: 0, transitions: [] }
: pipeline,
},
{
path: "/api/pipelines/pipeline-gap/cases",
read: () =>
state === "empty" ? [] : [{ case: item, stage: detail().stage }],
},
{
path: "/api/pipelines/pipeline-gap/health",
read: () => ({ warnings: [], pipelineId: pipeline.id }),
},
{
path: "/api/pipelines/pipeline-gap/intake-form",
read: () => ({
pipelineId: pipeline.id,
stageId: "stage-work",
fields: [],
}),
},
{
path: "/api/companies/company-storybook/pipelines-attention",
read: () => ({
suggestions: [],
reviews: [],
headsUp: [],
counts: { suggestions: 0, reviews: 0, headsUp: 0 },
}),
},
{
path: "/api/companies/company-storybook/review-cases",
read: () => (state === "empty" || !review ? [] : [reviewRow()]),
},
{ path: "/api/cases/case-pipeline-gap", read: detail },
{
path: /^\/api\/cases\/case-pipeline-gap\/(children|events|issue-links)$/,
read: () => [],
},
{
path: "/api/cases/case-pipeline-gap/review",
method: "POST",
read: ({ body }) => {
review = false;
item = {
...item,
stageId: body.decision === "approve" ? "stage-done" : "stage-work",
version: 2,
};
return detail();
},
},
{
path: "/api/companies/company-storybook/case-events",
read: () => ({
items: state === "empty" ? [] : [event],
pagination: {
limit: 50,
offset: 0,
nextOffset: null,
hasMore: false,
order: "desc",
},
}),
},
],
state,
);
}
@@ -0,0 +1,110 @@
import { prepareFixtureApi, type FixtureState } from "./api";
export function installPluginFixtures(
state: FixtureState = "ready",
empty = false,
pluginId = "plugin-gap",
) {
let plugin = {
id: pluginId,
pluginKey: "storybook.fixture",
packageName: "@paperclipai/storybook-fixture",
version: "1.0.0",
status: "ready",
categories: ["automation"],
manifestJson: {
displayName: "Preview plugin",
version: "1.0.0",
description: "An isolated plugin fixture.",
capabilities: [],
instanceConfigSchema: {
type: "object",
properties: { label: { type: "string", title: "Label" } },
required: ["label"],
},
},
lastError: null as string | null,
packagePath: null,
createdAt: "2026-10-12T12:00:00Z",
updatedAt: "2026-10-12T12:00:00Z",
};
let rows = empty ? [] : [plugin];
let configJson: Record<string, unknown> = { label: "Release preview" };
const health = {
pluginId: plugin.id,
status: "ready",
healthy: true,
checks: [],
};
return prepareFixtureApi(
[
{ path: "/api/plugins", read: () => rows },
{ path: "/api/plugins/examples", read: () => [] },
{ path: "/api/plugins/ui-contributions", read: () => [] },
{
path: "/api/plugins/install",
method: "POST",
read: () => {
rows = [plugin];
return plugin;
},
},
{
path: `/api/plugins/${pluginId}`,
read: ({ method }) => {
if (method === "DELETE") {
rows = [];
return { ok: true };
}
return plugin;
},
},
{
path: new RegExp(`^/api/plugins/${pluginId}/(enable|disable)$`),
method: "POST",
read: ({ url }) => {
plugin = {
...plugin,
status: url.pathname.endsWith("disable") ? "disabled" : "ready",
lastError: url.pathname.endsWith("disable")
? "Disabled by operator"
: null,
};
rows = [plugin];
return { ok: true };
},
},
{ path: `/api/plugins/${pluginId}/health`, read: () => health },
{ path: `/api/plugins/${pluginId}/logs`, read: () => [] },
{
path: `/api/plugins/${pluginId}/dashboard`,
read: () => ({
pluginId: plugin.id,
worker: null,
recentJobRuns: [],
recentWebhookDeliveries: [],
health,
checkedAt: "2026-10-12T12:00:00Z",
}),
},
{
path: `/api/plugins/${pluginId}/config`,
read: ({ method, body }) => {
if (method === "POST")
configJson = body.configJson as Record<string, unknown>;
return {
id: "config-gap",
pluginId: plugin.id,
companyId: "company-storybook",
configJson,
};
},
},
{
path: `/api/plugins/${pluginId}/config/test`,
method: "POST",
read: () => ({ valid: true }),
},
],
state,
).cleanup;
}
@@ -0,0 +1,49 @@
import type { CompanySkillDetail } from "@paperclipai/shared";
export function makeSkill(
overrides: Partial<CompanySkillDetail> = {},
): CompanySkillDetail {
return {
id: "skill-gap",
companyId: "company-storybook",
key: "paperclip/demo-skill",
slug: "demo-skill",
name: "Demo Skill",
description: "A demo skill.",
markdown:
"---\nname: Demo Skill\ndescription: Existing\n---\n\n# Demo Skill\n",
sourceType: "local_path",
sourceLocator: null,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
iconUrl: null,
color: null,
tagline: "Existing tagline",
authorName: null,
homepageUrl: null,
categories: ["engineering"],
sharingScope: "company",
publicShareToken: null,
forkedFromSkillId: null,
forkedFromCompanyId: null,
starCount: 0,
installCount: 0,
forkCount: 0,
currentVersionId: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
attachedAgentCount: 0,
usedByAgents: [],
editable: true,
editableReason: null,
sourceLabel: "Local",
sourceBadge: "local",
sourcePath: null,
currentVersion: null,
starredByCurrentActor: false,
existingForks: [],
...overrides,
};
}
@@ -0,0 +1,54 @@
import type { StatusCard } from "@paperclipai/shared";
import type { StatusCardView } from "@/pages/StatusCards/types";
export function makeStatusCard(
overrides: Partial<StatusCardView> = {},
): StatusCardView {
const card: StatusCard = {
id: "card-gap",
companyId: "company-storybook",
createdByUserId: null,
createdByAgentId: null,
title: "Release readiness",
titlePinned: false,
interestPrompt: "issues that help ship the new install subcommand",
queries: [],
queryVersion: 1,
queryCompiledAt: "2026-07-22T10:00:00.000Z",
queryCompiledByAgentId: null,
agentId: null,
refreshPolicy: {
mode: "interval",
intervalMinutes: 15,
triggers: {
statusTransitions: true,
membershipChanges: true,
humanComments: true,
assigneeChanges: true,
anyUpdate: false,
},
},
state: "active",
pendingChangeCount: 0,
lastChangeAt: null,
fingerprint: null,
fingerprintAt: null,
mentionedIssueIds: [],
documentId: null,
lastUpdateRunKind: "full",
lastGeneratedAt: "2026-07-22T11:00:00.000Z",
lastModel: "claude-haiku",
generatingIssueId: null,
failureReason: null,
nextEvalAt: null,
archivedAt: null,
archivedByUserId: null,
archivedByAgentId: null,
createdAt: "2026-07-22T09:00:00.000Z",
updatedAt: "2026-07-22T11:00:00.000Z",
};
return {
...card,
summaryBody: "All on track. Next: review the deep-link fix, then merge.",
...overrides,
};
}