docs(storybook): add work-folder components and page review gallery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
5b12157460
commit
28858075ef
|
|
@ -0,0 +1,55 @@
|
|||
# Work-folder design review
|
||||
|
||||
Run `pnpm storybook` from the repository root. Open the **Work folders** group.
|
||||
The default URL is `http://localhost:6006`.
|
||||
|
||||
## Components
|
||||
|
||||
`Work folders / Components` renders the production `WorkFolderBrowser` and
|
||||
`WorkFolderButton`. It includes the browser, its trigger and dialog, Markdown,
|
||||
code, image and empty-file previews, unsupported/large-file messages, loading,
|
||||
empty, saving, failed-save, unavailable-storage, failed-upload, trash, and
|
||||
permanent-deletion confirmation states. `Upload Delete Restore` exercises the
|
||||
full recovery interaction and leaves the restored file available for review.
|
||||
|
||||
Use **Controls** to change task/agent/project/user scope or the fixture state.
|
||||
Use Storybook's theme and viewport toolbar controls for light/dark and mobile
|
||||
review. The fixtures reset when you reload the story or change its controls.
|
||||
|
||||
## Pages
|
||||
|
||||
`Work folders / Pages` mounts the actual route pages inside `Layout`:
|
||||
|
||||
| Page | Current entry point | Stories |
|
||||
| ---------------- | ---------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Task detail | Task files above the task thread | Task Page, Task Files Open, Task Save Failed |
|
||||
| Agent detail | Agent files in the detail header | Agent Page, Agent Files Open |
|
||||
| Project detail | Project files in the project header | Project Page, Project Files Open, Project Empty Folder |
|
||||
| Profile settings | My files below the profile and inbox policy settings | Profile Settings Page, My Files Open |
|
||||
|
||||
The page stories preserve existing placement, copy, and spacing. They are a
|
||||
review surface for the current implementation, not proposed redesigns. The
|
||||
Open stories activate the page's real button automatically. Close the dialog
|
||||
to review its surrounding page.
|
||||
|
||||
## Fixture boundaries
|
||||
|
||||
Uploads, previews, downloads, folder creation, deletion, restoration, purge,
|
||||
and refresh use a fresh in-memory API per story. They do not contact a tenant,
|
||||
launch agents, or persist files. Downloads contain fixture data only. Other
|
||||
page mutations display an explicit unsupported-demo message. The story restores
|
||||
its fetch handler and clears its isolated query cache on unmount. The loading
|
||||
scenario intentionally remains pending until you leave that story.
|
||||
|
||||
Saving and failed states are fixed examples for visual review. Refresh shows the
|
||||
real acknowledgement, but does not launch a sandbox. These stories do not replace
|
||||
the deployed persistence and runner acceptance matrix.
|
||||
|
||||
## Supporting UI
|
||||
|
||||
The feature stack also changes the native runner's Pi ACPX selector and
|
||||
Cloud-aware CLI authorization. **Work folders / Supporting UI** shows the real
|
||||
runtime fields and the request, approved, sign-in-required, and expired CLI
|
||||
pages. These use fictional challenges and cannot grant access or create keys.
|
||||
The file viewer's shared rendering is covered by the component preview stories;
|
||||
the design-guide examples use the same browser component.
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookAuthSession,
|
||||
storybookCompanies,
|
||||
storybookIssues,
|
||||
storybookProjects,
|
||||
} from "./paperclipData";
|
||||
import {
|
||||
createWorkFolderFixture,
|
||||
WORK_FOLDER_COMPANY,
|
||||
type WorkFolderScenario,
|
||||
} from "./workFolders";
|
||||
|
||||
export const workFolderTask = {
|
||||
...storybookIssues[0]!,
|
||||
title: "Prepare the launch report",
|
||||
description:
|
||||
"Review the launch brief and collect findings in the task folder. Keep the final report and supporting files available for the next run.",
|
||||
status: "in_review",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionWorkspaceId: null,
|
||||
currentExecutionWorkspace: null,
|
||||
responsibleUserId: "user-board",
|
||||
ancestors: [],
|
||||
children: [],
|
||||
};
|
||||
export const workFolderProject = storybookProjects[0]!;
|
||||
export const workFolderAgent = {
|
||||
...storybookAgents[0]!,
|
||||
chainOfCommand: [],
|
||||
access: {
|
||||
canAssignTasks: true,
|
||||
taskAssignSource: "explicit_grant",
|
||||
membership: null,
|
||||
grants: [],
|
||||
},
|
||||
};
|
||||
|
||||
function seed(client: QueryClient) {
|
||||
const company = WORK_FOLDER_COMPANY;
|
||||
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
|
||||
client.setQueryData(queryKeys.liveRuns(company), []);
|
||||
client.setQueryData(queryKeys.inboxAgentPolicy.mine(company), {
|
||||
companyId: company,
|
||||
userId: "user-board",
|
||||
mode: "open",
|
||||
allowedAgentIds: [],
|
||||
materialized: true,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
});
|
||||
client.setQueryData(
|
||||
queryKeys.companies.list(storybookAuthSession.user.id),
|
||||
storybookCompanies,
|
||||
);
|
||||
client.setQueryData(queryKeys.agents.list(company), storybookAgents);
|
||||
client.setQueryData(queryKeys.projects.list(company), storybookProjects);
|
||||
client.setQueryData(queryKeys.issues.list(company), [workFolderTask]);
|
||||
for (const ref of [workFolderTask.id, workFolderTask.identifier!]) {
|
||||
client.setQueryData(queryKeys.issues.detail(ref), workFolderTask);
|
||||
client.setQueryData(queryKeys.issues.comments(ref), {
|
||||
pages: [[]],
|
||||
pageParams: [null],
|
||||
});
|
||||
for (const name of [
|
||||
"activity",
|
||||
"runs",
|
||||
"approvals",
|
||||
"attachments",
|
||||
"workProducts",
|
||||
"liveRuns",
|
||||
"interactions",
|
||||
"queuedComments",
|
||||
] as const)
|
||||
client.setQueryData(queryKeys.issues[name](ref), []);
|
||||
client.setQueryData(queryKeys.issues.activeRun(ref), null);
|
||||
}
|
||||
for (const ref of [workFolderProject.id, workFolderProject.urlKey!])
|
||||
client.setQueryData(
|
||||
[...queryKeys.projects.detail(ref), company],
|
||||
workFolderProject,
|
||||
);
|
||||
for (const ref of [workFolderAgent.id, workFolderAgent.urlKey!])
|
||||
client.setQueryData(
|
||||
[...queryKeys.agents.detail(ref), company],
|
||||
workFolderAgent,
|
||||
);
|
||||
client.setQueryData(queryKeys.agents.runtimeState(workFolderAgent.id), null);
|
||||
client.setQueryData(queryKeys.heartbeats(company, workFolderAgent.id), []);
|
||||
client.setQueryData(
|
||||
[
|
||||
...queryKeys.issues.list(company),
|
||||
"participant-agent",
|
||||
workFolderAgent.id,
|
||||
],
|
||||
[workFolderTask],
|
||||
);
|
||||
client.setQueryData(
|
||||
queryKeys.issues.listByProject(company, workFolderProject.id),
|
||||
[workFolderTask],
|
||||
);
|
||||
client.setQueryData(queryKeys.budgets.overview(company), {
|
||||
companyId: company,
|
||||
policies: [],
|
||||
activeIncidents: [],
|
||||
pausedAgentCount: 0,
|
||||
pausedProjectCount: 0,
|
||||
pendingApprovalCount: 0,
|
||||
});
|
||||
client.setQueryData(queryKeys.resourceMemberships.mine(company), {
|
||||
projectMemberships: {},
|
||||
agentMemberships: {},
|
||||
starredProjects: [],
|
||||
starredAgents: [],
|
||||
});
|
||||
client.setQueryData(queryKeys.access.currentBoardAccess, {
|
||||
isInstanceAdmin: true,
|
||||
canCreateCompanies: true,
|
||||
});
|
||||
client.setQueryData(queryKeys.builtInAgents.list(company), []);
|
||||
client.setQueryData(queryKeys.instance.generalSettings, {
|
||||
keyboardShortcutsEnabled: false,
|
||||
});
|
||||
client.setQueryData(queryKeys.instance.experimentalSettings, {
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableManagedSandboxOnly: true,
|
||||
});
|
||||
client.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
deploymentMode: "authenticated",
|
||||
authReady: true,
|
||||
companyCreationPolicy: "restricted",
|
||||
});
|
||||
}
|
||||
|
||||
/** Mounted only for work-folder stories; resets cache, mutations, and fetch handlers on exit. */
|
||||
export function WorkFolderStoryProvider({
|
||||
scenario = "saved",
|
||||
children,
|
||||
}: {
|
||||
scenario?: WorkFolderScenario;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [client] = useState(() => {
|
||||
const value = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
seed(value);
|
||||
return value;
|
||||
});
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
const fixture = createWorkFolderFixture(scenario);
|
||||
const originalFetch = window.fetch;
|
||||
const controller = new AbortController();
|
||||
const wrapped: typeof window.fetch = async (input, init) => {
|
||||
const request = new Request(
|
||||
input instanceof Request
|
||||
? input
|
||||
: new URL(String(input), window.location.origin),
|
||||
{ ...init, signal: controller.signal },
|
||||
);
|
||||
const response = await fixture.handle(request);
|
||||
if (response) return response;
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith("/api/") && request.method !== "GET") {
|
||||
// Incidental page read markers are harmless. Other page mutations are outside this demo.
|
||||
if (url.pathname.endsWith("/read"))
|
||||
return Response.json({
|
||||
id: workFolderTask.id,
|
||||
lastReadAt: new Date().toISOString(),
|
||||
});
|
||||
return Response.json(
|
||||
{ error: "This Storybook only simulates work-folder actions." },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
if (/^\/api\/issues\/[^/]+\/(watchdog|active-run)$/.test(url.pathname))
|
||||
return Response.json(null);
|
||||
if (
|
||||
/^\/api\/issues\/[^/]+\/(comments|activity|runs|approvals|attachments|work-products|live-runs|interactions|queued-comments|feedback-votes)$/.test(
|
||||
url.pathname,
|
||||
)
|
||||
)
|
||||
return Response.json([]);
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
window.fetch = wrapped;
|
||||
// Anchor downloads are native navigations, so supply the same in-memory content as previews.
|
||||
const download = async (event: MouseEvent) => {
|
||||
const anchor = (event.target as Element).closest?.(
|
||||
"a[download]",
|
||||
) as HTMLAnchorElement | null;
|
||||
if (!anchor || !anchor.pathname.includes("/work-folders/")) return;
|
||||
event.preventDefault();
|
||||
const response = await fixture.handle(new Request(anchor.href));
|
||||
if (!response?.ok) return;
|
||||
const href = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement("a");
|
||||
link.href = href;
|
||||
link.download =
|
||||
new URL(anchor.href).searchParams.get("path")?.split("/").at(-1) ??
|
||||
"file";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 1000);
|
||||
};
|
||||
document.addEventListener("click", download);
|
||||
setReady(true);
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (window.fetch === wrapped) window.fetch = originalFetch;
|
||||
document.removeEventListener("click", download);
|
||||
client.clear();
|
||||
};
|
||||
}, [client, scenario]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
{ready ? children : null}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import type {
|
||||
WorkFile,
|
||||
WorkFolderOwner,
|
||||
WorkFolderScope,
|
||||
WorkFolderSyncStatus,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export type WorkFolderScenario =
|
||||
| "saved"
|
||||
| "saving"
|
||||
| "failed"
|
||||
| "empty"
|
||||
| "loading"
|
||||
| "unavailable"
|
||||
| "uploadFailed";
|
||||
export const WORK_FOLDER_COMPANY = "company-storybook";
|
||||
export const workFolderOwners: Record<WorkFolderScope, WorkFolderOwner> = {
|
||||
task: {
|
||||
companyId: WORK_FOLDER_COMPANY,
|
||||
scope: "task",
|
||||
ownerId: "issue-storybook-1",
|
||||
},
|
||||
agent: {
|
||||
companyId: WORK_FOLDER_COMPANY,
|
||||
scope: "agent",
|
||||
ownerId: "agent-codex",
|
||||
},
|
||||
project: {
|
||||
companyId: WORK_FOLDER_COMPANY,
|
||||
scope: "project",
|
||||
ownerId: "project-board-ui",
|
||||
},
|
||||
user: {
|
||||
companyId: WORK_FOLDER_COMPANY,
|
||||
scope: "user",
|
||||
ownerId: "user-board",
|
||||
},
|
||||
};
|
||||
export const workFolderLabels = {
|
||||
task: "Task files",
|
||||
agent: "Agent files",
|
||||
project: "Project files",
|
||||
user: "My files",
|
||||
};
|
||||
const savedAt = "2026-09-08T14:00:00.000Z";
|
||||
const updatedAt = "2026-09-08T14:01:00.000Z";
|
||||
|
||||
/** A fresh, in-memory API per mounted story. No real files or server are used. */
|
||||
export function createWorkFolderFixture(
|
||||
scenario: WorkFolderScenario = "saved",
|
||||
) {
|
||||
const collections = new Map<
|
||||
string,
|
||||
{ files: WorkFile[]; contents: Map<string, Blob>; changed: boolean }
|
||||
>();
|
||||
let revision = 0;
|
||||
function collection(scope: string, ownerId: string) {
|
||||
const key = `${scope}:${ownerId}`;
|
||||
const existing = collections.get(key);
|
||||
if (existing) return existing;
|
||||
const files: WorkFile[] = [];
|
||||
const contents = new Map<string, Blob>();
|
||||
function add(
|
||||
path: string,
|
||||
content: string,
|
||||
options: Partial<WorkFile> = {},
|
||||
) {
|
||||
const blob = new Blob([content], {
|
||||
type: options.contentType ?? "text/plain",
|
||||
});
|
||||
files.push({
|
||||
id: `${key}:${path}`,
|
||||
path,
|
||||
kind: "file",
|
||||
byteSize: blob.size,
|
||||
sha256: `fixture-${path}`,
|
||||
executable: false,
|
||||
contentType: blob.type,
|
||||
deletedAt: null,
|
||||
updatedAt: savedAt,
|
||||
...options,
|
||||
});
|
||||
contents.set(path, blob);
|
||||
}
|
||||
if (scenario !== "empty") {
|
||||
add(
|
||||
"README.md",
|
||||
`# ${workFolderLabels[scope as WorkFolderScope]}\n\nFiles available to this ${scope}'s sandbox runs.\n\n## Current work\n\n- Review the launch brief\n- Keep the original uploads\n- Share the final report\n`,
|
||||
{ contentType: "text/markdown" },
|
||||
);
|
||||
add(
|
||||
"briefs/launch.md",
|
||||
"# Launch brief\n\nMake it easy to find, review, and recover the files an agent creates.\n",
|
||||
{ contentType: "text/markdown" },
|
||||
);
|
||||
add(
|
||||
"reports/findings.json",
|
||||
'{\n "status": "review",\n "checks": 12,\n "openQuestions": 2\n}\n',
|
||||
{ contentType: "application/json" },
|
||||
);
|
||||
add(
|
||||
"scripts/verify.sh",
|
||||
'#!/usr/bin/env bash\nset -euo pipefail\nprintf "Ready for review\\n"\n',
|
||||
{ executable: true },
|
||||
);
|
||||
add("empty.txt", "");
|
||||
add("archive.zip", "Binary archive fixture", {
|
||||
contentType: "application/zip",
|
||||
});
|
||||
add("recording.mp4", "Large file fixture", {
|
||||
contentType: "video/mp4",
|
||||
byteSize: 12 * 1024 * 1024,
|
||||
});
|
||||
add(
|
||||
"drafts/old-notes.md",
|
||||
"Superseded draft; recoverable until purged.",
|
||||
{ deletedAt: savedAt },
|
||||
);
|
||||
add(
|
||||
"research/customer-interviews/round-two/detailed-feedback-and-next-steps.md",
|
||||
"# Interview notes\n\nA deliberately long nested path for layout review.\n",
|
||||
);
|
||||
add("assets/paperclip.png", "", { contentType: "image/png" });
|
||||
}
|
||||
const value = { files, contents, changed: false };
|
||||
collections.set(key, value);
|
||||
return value;
|
||||
}
|
||||
async function handle(request: Request): Promise<Response | null> {
|
||||
const url = new URL(request.url);
|
||||
const match = url.pathname.match(
|
||||
/^\/api\/companies\/company-storybook\/work-folders\/(task|agent|user|project)\/([^/]+)(?:\/(content|operations|sync|refresh))?$/,
|
||||
);
|
||||
if (!match) return null;
|
||||
const [, scope, ownerId, action] = match;
|
||||
const state = collection(scope, decodeURIComponent(ownerId));
|
||||
const fail = (message: string, status = 503) =>
|
||||
Response.json({ error: message }, { status });
|
||||
if (action === "sync") {
|
||||
const status: WorkFolderSyncStatus = {
|
||||
runId: "run-storybook",
|
||||
state:
|
||||
scenario === "saving"
|
||||
? "saving"
|
||||
: scenario === "failed"
|
||||
? "failed"
|
||||
: "saved",
|
||||
lastSavedAt: savedAt,
|
||||
active: scenario === "saving" || scenario === "failed",
|
||||
refreshRequested: false,
|
||||
error:
|
||||
scenario === "failed"
|
||||
? "Storage is unavailable. Your working files are retained in the sandbox; retry when storage recovers."
|
||||
: null,
|
||||
};
|
||||
return Response.json([status]);
|
||||
}
|
||||
if (action === "refresh") return Response.json({ ok: true });
|
||||
if (!action) {
|
||||
if (scenario === "loading")
|
||||
return new Promise<Response>((_resolve, reject) =>
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new DOMException("Aborted", "AbortError")),
|
||||
{ once: true },
|
||||
),
|
||||
);
|
||||
if (scenario === "unavailable")
|
||||
return fail(
|
||||
"Files could not be loaded. Try again when storage is available.",
|
||||
);
|
||||
const trash = url.searchParams.get("trash") === "true";
|
||||
return Response.json({
|
||||
id: `${scope}-${ownerId}`,
|
||||
owner: { companyId: WORK_FOLDER_COMPANY, scope, ownerId },
|
||||
files: state.files.filter((f) => Boolean(f.deletedAt) === trash),
|
||||
nextCursor: null,
|
||||
lastOperationAt: state.changed ? updatedAt : null,
|
||||
});
|
||||
}
|
||||
const path = url.searchParams.get("path") ?? "";
|
||||
if (action === "content" && request.method === "GET") {
|
||||
if (path === "assets/paperclip.png")
|
||||
return fetch("/android-chrome-192x192.png", { signal: request.signal });
|
||||
const blob = state.contents.get(path);
|
||||
return blob
|
||||
? new Response(blob, {
|
||||
headers: {
|
||||
"Content-Type": blob.type,
|
||||
"Content-Length": String(blob.size),
|
||||
},
|
||||
})
|
||||
: fail("File not found", 404);
|
||||
}
|
||||
if (action === "content" && request.method === "PUT") {
|
||||
if (scenario === "uploadFailed")
|
||||
return fail(
|
||||
"Upload failed: storage is unavailable. Your existing files were not changed.",
|
||||
);
|
||||
const blob = await request.blob();
|
||||
state.contents.set(path, blob);
|
||||
state.files = state.files.filter((f) => f.path !== path);
|
||||
state.files.push({
|
||||
id: `${scope}:${ownerId}:${++revision}`,
|
||||
path,
|
||||
kind: "file",
|
||||
byteSize: blob.size,
|
||||
sha256: `uploaded-${revision}`,
|
||||
executable: false,
|
||||
contentType: request.headers.get("X-File-Content-Type") ?? blob.type,
|
||||
deletedAt: null,
|
||||
updatedAt,
|
||||
});
|
||||
} else if (action === "operations") {
|
||||
const op = (await request.json()) as {
|
||||
action: string;
|
||||
path?: string;
|
||||
fileId?: string;
|
||||
};
|
||||
if (op.action === "mkdir")
|
||||
state.files.push({
|
||||
id: `directory-${++revision}`,
|
||||
path: op.path!,
|
||||
kind: "directory",
|
||||
byteSize: 0,
|
||||
sha256: null,
|
||||
executable: false,
|
||||
contentType: "application/octet-stream",
|
||||
deletedAt: null,
|
||||
updatedAt,
|
||||
});
|
||||
if (op.action === "delete")
|
||||
state.files.forEach((f) => {
|
||||
if (f.path === op.path || f.path.startsWith(`${op.path}/`))
|
||||
f.deletedAt = updatedAt;
|
||||
});
|
||||
const selected = state.files.find((f) => f.id === op.fileId);
|
||||
const inDeletedTree = (f: WorkFile) =>
|
||||
Boolean(
|
||||
selected &&
|
||||
f.deletedAt &&
|
||||
(f.id === selected.id || f.path.startsWith(`${selected.path}/`)),
|
||||
);
|
||||
if (op.action === "restore")
|
||||
state.files.forEach((f) => {
|
||||
if (inDeletedTree(f)) f.deletedAt = null;
|
||||
});
|
||||
if (op.action === "purge")
|
||||
state.files = state.files.filter((f) => !inDeletedTree(f));
|
||||
} else return fail("Unsupported fixture operation", 405);
|
||||
state.changed = true;
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
return { handle };
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import { useEffect } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { IssueDetail } from "@/pages/IssueDetail";
|
||||
import { AgentDetail } from "@/pages/AgentDetail";
|
||||
import { ProjectDetail } from "@/pages/ProjectDetail";
|
||||
import { ProfileSettings } from "@/pages/ProfileSettings";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import {
|
||||
WorkFolderStoryProvider,
|
||||
workFolderAgent,
|
||||
workFolderProject,
|
||||
workFolderTask,
|
||||
} from "../fixtures/WorkFolderStoryProvider";
|
||||
import {
|
||||
WORK_FOLDER_COMPANY,
|
||||
workFolderLabels,
|
||||
type WorkFolderScenario,
|
||||
} from "../fixtures/workFolders";
|
||||
import type { WorkFolderScope } from "@paperclipai/shared";
|
||||
|
||||
const paths = {
|
||||
task: `/PAP/issues/${workFolderTask.identifier}`,
|
||||
agent: `/PAP/agents/${workFolderAgent.urlKey}`,
|
||||
project: `/PAP/projects/${workFolderProject.urlKey}`,
|
||||
user: "/PAP/company/settings/instance/profile",
|
||||
};
|
||||
function Page({ scope }: { scope: WorkFolderScope }) {
|
||||
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const onRoute = location.pathname.startsWith(paths[scope]);
|
||||
useEffect(() => {
|
||||
if (selectedCompanyId !== WORK_FOLDER_COMPANY)
|
||||
setSelectedCompanyId(WORK_FOLDER_COMPANY);
|
||||
}, [selectedCompanyId, setSelectedCompanyId]);
|
||||
useEffect(() => {
|
||||
if (!onRoute) navigate(paths[scope], { replace: true });
|
||||
}, [scope, onRoute, navigate]);
|
||||
if (selectedCompanyId !== WORK_FOLDER_COMPANY || !onRoute) return null;
|
||||
return (
|
||||
<PluginLauncherProvider>
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="agents/:agentId/:tab?" element={<AgentDetail />} />
|
||||
<Route path="projects/:projectId/:tab?" element={<ProjectDetail />} />
|
||||
<Route
|
||||
path="company/settings/instance/profile"
|
||||
element={<ProfileSettings />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Work folders/Pages",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Real production pages inside the application Layout, including sidebar, breadcrumb, and original file-button placement. These are current designs, not redesigned mockups. Task files are in the task thread, Agent files and Project files are in their detail pages, and My files is at the bottom of Profile settings. The Open stories click the actual entry point. Work-folder actions use disposable in-memory data; other page edits are not simulated.",
|
||||
},
|
||||
},
|
||||
},
|
||||
args: { scope: "task", scenario: "saved" },
|
||||
argTypes: {
|
||||
scope: { control: false },
|
||||
scenario: {
|
||||
control: "select",
|
||||
options: ["saved", "saving", "failed", "empty", "unavailable"],
|
||||
},
|
||||
},
|
||||
render: ({
|
||||
scope,
|
||||
scenario,
|
||||
}: {
|
||||
scope: WorkFolderScope;
|
||||
scenario: WorkFolderScenario;
|
||||
}) => (
|
||||
<WorkFolderStoryProvider key={`${scope}:${scenario}`} scenario={scenario}>
|
||||
<Page scope={scope} />
|
||||
</WorkFolderStoryProvider>
|
||||
),
|
||||
} satisfies Meta<{ scope: WorkFolderScope; scenario: WorkFolderScenario }>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
const openFiles: NonNullable<Story["play"]> = async ({
|
||||
canvasElement,
|
||||
args,
|
||||
}) => {
|
||||
const label = workFolderLabels[args.scope];
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole(
|
||||
"button",
|
||||
{ name: label },
|
||||
{ timeout: 15000 },
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
await within(canvasElement.ownerDocument.body).findByRole("dialog", {
|
||||
name: label,
|
||||
}),
|
||||
).toBeVisible();
|
||||
};
|
||||
export const TaskPage: Story = { args: { scope: "task" } };
|
||||
export const TaskFilesOpen: Story = {
|
||||
args: { scope: "task" },
|
||||
play: openFiles,
|
||||
};
|
||||
export const AgentPage: Story = { args: { scope: "agent" } };
|
||||
export const AgentFilesOpen: Story = {
|
||||
args: { scope: "agent" },
|
||||
play: openFiles,
|
||||
};
|
||||
export const ProjectPage: Story = { args: { scope: "project" } };
|
||||
export const ProjectFilesOpen: Story = {
|
||||
args: { scope: "project" },
|
||||
play: openFiles,
|
||||
};
|
||||
export const ProfileSettingsPage: Story = { args: { scope: "user" } };
|
||||
export const MyFilesOpen: Story = { args: { scope: "user" }, play: openFiles };
|
||||
export const TaskSaveFailed: Story = {
|
||||
args: { scope: "task", scenario: "failed" },
|
||||
play: openFiles,
|
||||
};
|
||||
export const ProjectEmptyFolder: Story = {
|
||||
args: { scope: "project", scenario: "empty" },
|
||||
play: openFiles,
|
||||
};
|
||||
export const MobileTaskFilesOpen: Story = {
|
||||
args: { scope: "task" },
|
||||
globals: { viewport: { value: "mobile" } },
|
||||
play: openFiles,
|
||||
};
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { CodexLocalConfigFields } from "@/adapters/codex-local/config-fields";
|
||||
import { CliAuthPage } from "@/pages/CliAuth";
|
||||
import { WorkFolderStoryProvider } from "../fixtures/WorkFolderStoryProvider";
|
||||
|
||||
function PiConfiguration() {
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({
|
||||
provider: "acpx",
|
||||
acpxAgent: "pi",
|
||||
model: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
lifecycleMode: "warm",
|
||||
acpxPermissionMode: "approve-all",
|
||||
});
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">Native runner configuration</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The existing runtime fields with the new Pi option selected. Changes
|
||||
stay in this story.
|
||||
</p>
|
||||
<CodexLocalConfigFields
|
||||
mode="edit"
|
||||
isCreate={false}
|
||||
adapterType="paperclip_runner"
|
||||
values={null}
|
||||
set={null}
|
||||
config={config}
|
||||
eff={(_group, _field, original) => original}
|
||||
mark={(_group, field, value) =>
|
||||
setConfig((previous) => ({ ...previous, [field]: value }))
|
||||
}
|
||||
models={[]}
|
||||
hideInstructionsFile
|
||||
managedSandboxOnly
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CliAuthScenario({
|
||||
state,
|
||||
}: {
|
||||
state: "pending" | "approved" | "signin" | "expired";
|
||||
}) {
|
||||
const client = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const path = "/cli-auth/storybook-challenge";
|
||||
useState(() => {
|
||||
client.setQueryData(
|
||||
["cli-auth-challenge", "storybook-challenge", "storybook-demo"],
|
||||
{
|
||||
id: "storybook-challenge",
|
||||
status: state === "signin" ? "pending" : state,
|
||||
requiresSignIn: state === "signin",
|
||||
canApprove: state === "pending",
|
||||
command: "paperclipai auth login",
|
||||
clientName: "Design review fixture",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyName: "Paperclip Storybook",
|
||||
},
|
||||
);
|
||||
return true;
|
||||
});
|
||||
const onRoute = location.pathname === path;
|
||||
useEffect(() => {
|
||||
if (!onRoute) navigate(`${path}?token=storybook-demo`, { replace: true });
|
||||
}, [onRoute, navigate]);
|
||||
return onRoute ? (
|
||||
<Routes>
|
||||
<Route path="/cli-auth/:id" element={<CliAuthPage />} />
|
||||
</Routes>
|
||||
) : null;
|
||||
}
|
||||
const meta = {
|
||||
title: "Work folders/Supporting UI",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The two supporting UI changes in the feature stack: the Pi ACPX selector and Cloud-aware CLI approval states. These render the real components. The CLI challenge is fictional; these stories do not grant access or create credentials.",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<WorkFolderStoryProvider>
|
||||
<Story />
|
||||
</WorkFolderStoryProvider>
|
||||
),
|
||||
],
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const NativePiConfiguration: Story = {
|
||||
render: () => <PiConfiguration />,
|
||||
};
|
||||
export const CliAccessRequest: Story = {
|
||||
render: () => <CliAuthScenario state="pending" />,
|
||||
};
|
||||
export const CliAccessApproved: Story = {
|
||||
render: () => <CliAuthScenario state="approved" />,
|
||||
};
|
||||
export const CliSignInRequired: Story = {
|
||||
render: () => <CliAuthScenario state="signin" />,
|
||||
};
|
||||
export const CliRequestExpired: Story = {
|
||||
render: () => <CliAuthScenario state="expired" />,
|
||||
};
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import {
|
||||
WorkFolderBrowser,
|
||||
WorkFolderButton,
|
||||
} from "@/components/WorkFolderBrowser";
|
||||
import { WorkFolderStoryProvider } from "../fixtures/WorkFolderStoryProvider";
|
||||
import {
|
||||
workFolderLabels,
|
||||
workFolderOwners,
|
||||
type WorkFolderScenario,
|
||||
} from "../fixtures/workFolders";
|
||||
import type { WorkFolderScope } from "@paperclipai/shared";
|
||||
|
||||
type Args = {
|
||||
scope: WorkFolderScope;
|
||||
scenario: WorkFolderScenario;
|
||||
presentation: "browser" | "button";
|
||||
};
|
||||
const meta = {
|
||||
title: "Work folders/Components",
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The actual reusable WorkFolderBrowser and WorkFolderButton. All file operations are in-memory fixtures: upload, create folder, delete, restore, purge, refresh, and download are safe to try. Change scope in Controls; use the toolbar for dark/light and mobile review. Reload a story to reset its files.",
|
||||
},
|
||||
},
|
||||
},
|
||||
args: { scope: "task", scenario: "saved", presentation: "browser" },
|
||||
argTypes: {
|
||||
scope: { control: "select", options: ["task", "agent", "project", "user"] },
|
||||
scenario: {
|
||||
control: "select",
|
||||
options: [
|
||||
"saved",
|
||||
"saving",
|
||||
"failed",
|
||||
"empty",
|
||||
"loading",
|
||||
"unavailable",
|
||||
"uploadFailed",
|
||||
],
|
||||
},
|
||||
presentation: { control: "radio", options: ["browser", "button"] },
|
||||
},
|
||||
render: ({ scope, scenario, presentation }) => (
|
||||
<WorkFolderStoryProvider
|
||||
key={`${scope}:${scenario}:${presentation}`}
|
||||
scenario={scenario}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{presentation === "button" ? (
|
||||
<WorkFolderButton
|
||||
owner={workFolderOwners[scope]}
|
||||
label={workFolderLabels[scope]}
|
||||
/>
|
||||
) : (
|
||||
<WorkFolderBrowser owner={workFolderOwners[scope]} />
|
||||
)}
|
||||
</div>
|
||||
</WorkFolderStoryProvider>
|
||||
),
|
||||
} satisfies Meta<Args>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const BrowseAndManage: Story = {};
|
||||
export const FileButton: Story = { args: { presentation: "button" } };
|
||||
export const OpenDialog: Story = {
|
||||
args: { presentation: "button" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Task files" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const MarkdownPreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(await within(canvasElement).findByText("README.md"));
|
||||
await expect(
|
||||
within(canvasElement).getByRole("link", { name: "Download" }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const NestedCodePreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByText("scripts"));
|
||||
await userEvent.click(await c.findByText("verify.sh"));
|
||||
},
|
||||
};
|
||||
export const ImagePreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByText("assets"));
|
||||
await userEvent.click(await c.findByText("paperclip.png"));
|
||||
},
|
||||
};
|
||||
export const EmptyFilePreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(await within(canvasElement).findByText("empty.txt"));
|
||||
},
|
||||
};
|
||||
export const UnsupportedPreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText("archive.zip"),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const LargeFilePreview: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByText("recording.mp4"),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const EmptyFolder: Story = { args: { scenario: "empty" } };
|
||||
export const Loading: Story = { args: { scenario: "loading" } };
|
||||
export const Saving: Story = { args: { scenario: "saving" } };
|
||||
export const SaveFailed: Story = { args: { scenario: "failed" } };
|
||||
export const StorageUnavailable: Story = { args: { scenario: "unavailable" } };
|
||||
export const UploadFailed: Story = {
|
||||
args: { scenario: "uploadFailed" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.upload(
|
||||
await within(canvasElement).findByLabelText("Upload work files"),
|
||||
new File(["review notes"], "review.md", { type: "text/markdown" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const Trash: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Trash" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PurgeConfirmation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Trash" }));
|
||||
await userEvent.click(await c.findByRole("button", { name: "Purge…" }));
|
||||
},
|
||||
};
|
||||
export const UploadDeleteRestore: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Exercises the real controls against a fresh fixture: upload a file, delete it, and restore it from Trash. Ends on the restored file list for further exploration.",
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.upload(
|
||||
await c.findByLabelText("Upload work files"),
|
||||
new File(["# Review\n\nReady for design feedback."], "review.md", {
|
||||
type: "text/markdown",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await c.findByText("review.md"));
|
||||
await userEvent.click(await c.findByRole("button", { name: "Delete" }));
|
||||
await userEvent.click(c.getByRole("button", { name: "Trash" }));
|
||||
const row = (await c.findByText("review.md")).parentElement!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "Restore" }));
|
||||
await userEvent.click(c.getByRole("button", { name: "Back to files" }));
|
||||
await expect(await c.findByText("review.md")).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const EmptyTrash: Story = {
|
||||
args: { scenario: "empty" },
|
||||
play: Trash.play,
|
||||
};
|
||||
export const MobileBrowser: Story = {
|
||||
globals: { viewport: { value: "mobile" } },
|
||||
};
|
||||
export const LightBrowser: Story = {
|
||||
globals: { theme: "light" },
|
||||
play: MarkdownPreview.play,
|
||||
};
|
||||
Loading…
Reference in New Issue