feat(ui): add experimental cached task file inspection

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 09:45:37 -05:00
parent 71774a29ca
commit 427678bb5a
20 changed files with 287 additions and 49 deletions

View File

@ -132,16 +132,19 @@ directories are not supported by this checkpoint format.
## API and UI
The task, agent, project, and current-user pages do not expose stored-file
browsing. The 2026-09-08 design review removed these entry points: durable copies
can lag behind a running sandbox and do not represent its complete filesystem.
The persistence and synchronization APIs remain available to the runtime.
Cached-file inspection is an opt-in development tool. Enable **Allow viewing
cached task files** in Experimental settings under Paperclip Developer Mode.
Task properties then show **Files → View cached files** beneath Execution in
the Workspace section. The dialog previews and downloads the task, project,
assigned agent, and responsible user's saved collections. Missing bindings are
empty; private user files are available only to that user. Existing server
ownership checks apply independently of the visibility setting.
Two separate features are deferred: debug inspection of persisted collections,
and authorized inspection of the live sandbox filesystem across its task,
agent, user, project, and repository context. The stored-file browser remains
only as an unshipped Storybook prototype. It must not be presented as a live
sandbox view. Page stories show the current pages without Files buttons.
The inspector is read-only and clearly identifies saved copies that can lag
behind agent edits. It does not list repositories or the live sandbox disk.
Agent, project, and profile pages have no standalone stored-file entry points.
Live sandbox filesystem inspection remains a separate future feature. The
editable stored-file browser remains in Storybook for design reference.
All routes start at
`/api/companies/:companyId/work-folders/:scope/:ownerId`:

View File

@ -213,6 +213,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableCachedTaskFiles: {
title: "Allow viewing cached task files",
description: "Inspect saved task, agent, responsible-user, and project files from task properties. These are cached copies, not the live sandbox filesystem.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,
},
enableServerInfoDebugView: {
title: "Server Info Debug View",
description:

View File

@ -76,6 +76,7 @@ export interface InstanceExperimentalSettings {
enableStatusCards: boolean;
enableDecisions: boolean;
enableGoalsSidebarLink: boolean;
enableCachedTaskFiles: boolean;
enableServerInfoDebugView: boolean;
/** Shows internal Paperclip maintainer tools and observability links. */
enablePaperclipDeveloperMode: boolean;

View File

@ -5,6 +5,13 @@ import {
} from "./instance.js";
describe("instance experimental settings validators", () => {
it("defaults cached task file inspection off and accepts opt-in and opt-out patches", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableCachedTaskFiles).toBe(false);
for (const enableCachedTaskFiles of [true, false]) {
expect(patchInstanceExperimentalSettingsSchema.parse({ enableCachedTaskFiles })).toEqual({ enableCachedTaskFiles });
}
});
it("defaults the streamlined UI on and accepts an explicit patch", () => {
expect(instanceExperimentalSettingsSchema.parse({}).enableStreamlinedUi).toBe(true);
expect(

View File

@ -64,6 +64,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableStatusCards: z.boolean().default(false),
enableDecisions: z.boolean().default(false),
enableGoalsSidebarLink: z.boolean().default(false),
enableCachedTaskFiles: z.boolean().default(false),
enableServerInfoDebugView: z.boolean().default(false),
enablePaperclipDeveloperMode: z.boolean().default(false),
enableSimplifiedEnglishInteractions: z.boolean().default(false),

View File

@ -7,6 +7,12 @@ import {
} from "../services/instance-settings.js";
describe("instance settings service", () => {
it("defaults cached task file inspection off and retains explicit saved settings", () => {
expect(normalizeExperimentalSettings({}).enableCachedTaskFiles).toBe(false);
expect(normalizeExperimentalSettings({ enableCachedTaskFiles: true }).enableCachedTaskFiles).toBe(true);
expect(normalizeExperimentalSettings({ enableCachedTaskFiles: false }).enableCachedTaskFiles).toBe(false);
});
it("ignores retired experimental flags without resetting current settings", () => {
expect(normalizeExperimentalSettings({
enableEnvironments: true,
@ -17,6 +23,7 @@ describe("instance settings service", () => {
enableExperimentalFileViewer: true,
enableBuiltInAgents: true,
enableGoalsSidebarLink: true,
enableCachedTaskFiles: false,
enableServerInfoDebugView: true,
enablePaperclipDeveloperMode: true,
autoRestartDevServerWhenIdle: true,
@ -45,6 +52,7 @@ describe("instance settings service", () => {
enableStatusCards: false,
enableDecisions: false,
enableGoalsSidebarLink: true,
enableCachedTaskFiles: false,
enableServerInfoDebugView: true,
enablePaperclipDeveloperMode: true,
enableSimplifiedEnglishInteractions: false,

View File

@ -242,6 +242,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableStatusCards: parsed.data.enableStatusCards ?? false,
enableDecisions: parsed.data.enableDecisions ?? false,
enableGoalsSidebarLink: parsed.data.enableGoalsSidebarLink ?? false,
enableCachedTaskFiles: parsed.data.enableCachedTaskFiles ?? false,
enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false,
enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false,
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
@ -279,6 +280,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableStatusCards: false,
enableDecisions: false,
enableGoalsSidebarLink: false,
enableCachedTaskFiles: false,
enableServerInfoDebugView: false,
enablePaperclipDeveloperMode: false,
enableSimplifiedEnglishInteractions: false,

View File

@ -0,0 +1,54 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CachedTaskFilesButton } from "./CachedTaskFilesButton";
vi.mock("@/components/WorkFolderBrowser", () => ({
WorkFolderBrowser: ({ owner, readOnly }: { owner: unknown; readOnly: boolean }) =>
<div data-testid="browser">{JSON.stringify({ owner, readOnly })}</div>,
}));
const issue = { id: "task-1", companyId: "company-1", projectId: "project-1", assigneeAgentId: "agent-1", responsibleUserId: "user-1" };
const roots: ReturnType<typeof createRoot>[] = [];
afterEach(async () => {
await act(async () => roots.splice(0).forEach((root) => root.unmount()));
document.body.innerHTML = "";
});
async function open(context: ComponentProps<typeof CachedTaskFilesButton>["issue"] = issue, currentUserId = "user-1") {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
roots.push(root);
await act(async () => root.render(<CachedTaskFilesButton issue={context} currentUserId={currentUserId} />));
await act(async () => container.querySelector("button")!.click());
}
async function select(label: string) {
const tab = [...document.querySelectorAll<HTMLButtonElement>('[role="tab"]')].find((node) => node.textContent === label)!;
await act(async () => { tab.focus(); });
}
describe("cached task context inspector", () => {
it("uses each task binding and exposes only inspection mode", async () => {
await open();
for (const [label, scope, ownerId] of [["Task", "task", "task-1"], ["Project", "project", "project-1"], ["Agent", "agent", "agent-1"], ["Responsible user", "user", "user-1"]]) {
await select(label!);
expect(JSON.parse(document.querySelector('[data-testid="browser"]')!.textContent!)).toEqual({ owner: { companyId: "company-1", scope, ownerId }, readOnly: true });
}
expect(document.body.textContent).toContain("not the live sandbox filesystem");
});
it("does not mount a private-user browser for a different viewer", async () => {
await open(issue, "another-user");
await select("Responsible user");
expect(document.body.textContent).toContain("private to the responsible user");
expect(document.querySelector('[data-testid="browser"]')).toBeNull();
});
it("leaves missing bindings unbound without selecting a substitute owner", async () => {
await open({ ...issue, projectId: null, assigneeAgentId: null, responsibleUserId: null });
for (const label of ["Project", "Agent", "Responsible user"]) {
await select(label);
expect(document.body.textContent).toContain("empty and unbound");
expect(document.querySelector('[data-testid="browser"]')).toBeNull();
}
});
});

View File

@ -0,0 +1,47 @@
import type { Issue, WorkFolderScope } from "@paperclipai/shared";
import { WorkFolderBrowser } from "@/components/WorkFolderBrowser";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type TaskContext = Pick<Issue, "id" | "companyId" | "assigneeAgentId" | "responsibleUserId" | "projectId">;
export function CachedTaskFilesButton({ issue, currentUserId }: { issue: TaskContext; currentUserId?: string }) {
const folders: { scope: WorkFolderScope; label: string; ownerId: string | null }[] = [
{ scope: "task", label: "Task", ownerId: issue.id },
{ scope: "project", label: "Project", ownerId: issue.projectId },
{ scope: "agent", label: "Agent", ownerId: issue.assigneeAgentId },
{ scope: "user", label: "Responsible user", ownerId: issue.responsibleUserId },
];
return (
<Dialog>
<DialogTrigger asChild>
<button type="button" className="text-sm text-primary hover:underline">View cached files</button>
</DialogTrigger>
<DialogContent className="max-h-dvh overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Cached task files</DialogTitle>
<DialogDescription>
Saved copies for this tasks current context, not the live sandbox filesystem. Changes are saved every three minutes and when a run ends, so these copies may be behind. Repository checkpoints are not browsable here.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="task">
<TabsList className="max-w-full overflow-x-auto overflow-y-hidden" aria-label="Cached file scope">
{folders.map(({ scope, label }) => <TabsTrigger key={scope} value={scope}>{label}</TabsTrigger>)}
</TabsList>
{folders.map(({ scope, label, ownerId }) => (
<TabsContent key={scope} value={scope} className="space-y-3">
<p className="text-xs text-muted-foreground">Cached copy of $HOME/{scope}/ · Preview and download only</p>
{!ownerId ? (
<p className="text-sm text-muted-foreground">No {label.toLowerCase()} is bound to this task. This folder is empty and unbound.</p>
) : scope === "user" && ownerId !== currentUserId ? (
<p className="text-sm text-muted-foreground">These cached files are private to the responsible user.</p>
) : (
<WorkFolderBrowser key={`${issue.companyId}:${scope}:${ownerId}`} owner={{ companyId: issue.companyId, scope, ownerId }} readOnly />
)}
</TabsContent>
))}
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -513,6 +513,15 @@ describe("IssueProperties", () => {
document.body.innerHTML = "";
});
it.each([false, true])("gates cached files in properties with the opt-in flag (%s)", async (enabled) => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCachedTaskFiles: enabled });
const root = renderProperties(container, { issue: createIssue({ projectId: null }), childIssues: [], onUpdate: vi.fn(), inline: true });
await waitForAssertion(() => expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 20));
expect(container.textContent?.includes("View cached files")).toBe(enabled);
act(() => root.unmount());
});
it("marks the task-detail property typography and section rhythm", () => {
const root = renderProperties(container, {
issue: createIssue(),

View File

@ -7,16 +7,22 @@ import { WorkFolderBrowser } from "./WorkFolderBrowser";
const owner = { companyId: "company", scope: "task" as const, ownerId: "task" };
const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId];
function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null) {
function render(statuses: WorkFolderSyncStatus[], lastOperationAt: string | null, readOnly = false) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData([...key, "files", false], { files: [], lastOperationAt });
client.setQueryData([...key, "sync"], statuses);
return renderToStaticMarkup(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} /></TooltipProvider></QueryClientProvider>);
return renderToStaticMarkup(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly={readOnly} /></TooltipProvider></QueryClientProvider>);
}
const checkpoint: WorkFolderSyncStatus = { runId: "run", state: "saved", active: false,
lastSavedAt: "2026-09-07T12:00:00.000Z", error: null, refreshRequested: false };
describe("work folder save feedback", () => {
it("keeps inspection free of controls that mutate the cache or refresh the sandbox", () => {
const html = render([checkpoint], null, true);
for (const action of ["Upload", "Create folder", "Refresh sandbox", "Delete", "Restore", "Purge"]) expect(html).not.toContain(action);
expect(html).toContain("Trash");
expect(html).toContain("No cached files have been saved for this scope.");
});
it("labels direct file operations separately from agent checkpoints", () => {
const html = render([checkpoint], "2026-09-07T12:01:00.000Z");
expect(html).toContain('role="status">Saved');

View File

@ -29,7 +29,7 @@ function tree(files: WorkFile[]) {
sort(root.children); return root.children;
}
export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[] }) {
export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean }) {
const queryClient = useQueryClient();
const [trash, setTrash] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
@ -48,6 +48,7 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw
const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256],
queryFn: () => workFoldersApi.preview(owner, selected!), enabled: !exampleFiles && !trash && selected?.kind === "file", retry: false });
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "delete"; path: string } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
if (readOnly) throw new Error("Cached file inspection is read-only.");
if (action.type === "upload") for (const file of action.files) await workFoldersApi.upload(owner, file, directory ? `${directory}/${file.name}` : file.name, crypto.randomUUID());
else if (action.type === "mkdir") { await workFoldersApi.operation(owner, { action: "mkdir", path: directory }, crypto.randomUUID()); setExpanded((before) => new Set([...before, directory])); }
else if (action.type === "delete") await workFoldersApi.operation(owner, { action: "delete", path: action.path }, crypto.randomUUID());
@ -67,18 +68,18 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw
const disabled = mutation.isPending || Boolean(exampleFiles);
return <div className="flex min-h-0 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" disabled={disabled || trash} onClick={() => fileInput.current?.click()}><Upload aria-hidden />Upload</Button>
{!readOnly && <><Button variant="outline" size="sm" disabled={disabled || trash} onClick={() => fileInput.current?.click()}><Upload aria-hidden />Upload</Button>
<input ref={fileInput} className="hidden" aria-label="Upload work files" type="file" multiple onChange={(event) => {
const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = "";
if (chosen.length) mutation.mutate({ type: "upload", files: chosen });
}} />
}} /></>}
<Button variant={trash ? "secondary" : "outline"} size="sm" onClick={() => { setTrash(!trash); setSelectedPath(null); }}><Trash2 aria-hidden />{trash ? "Back to files" : "Trash"}</Button>
<Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>
{!readOnly && <Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>}
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"}</span>
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
{lastOperation && (!lastSaved || lastOperation > lastSaved) && <span className="text-xs text-muted-foreground">Files updated {new Date(lastOperation).toLocaleTimeString()}</span>}
</div>
{!trash && <div className="flex flex-wrap items-end gap-2">
{!readOnly && !trash && <div className="flex flex-wrap items-end gap-2">
<div className="flex-1 space-y-1"><Label htmlFor={directoryId}>Folder path</Label><Input id={directoryId} value={directory} onChange={(event) => setDirectory(event.target.value)} placeholder="Root folder" /></div>
<Button variant="outline" size="sm" disabled={disabled || !directory} onClick={() => mutation.mutate({ type: "mkdir" })}><FolderPlus aria-hidden />Create folder</Button>
</div>}
@ -86,20 +87,20 @@ export function WorkFolderBrowser({ owner, exampleFiles }: { owner: WorkFolderOw
{failed && <p role="alert" className="text-sm text-destructive">{failed.error}</p>}
<p className="sr-only" aria-live="polite">{announcement}</p>
{trash ? <div className="max-h-96 overflow-auto">{files.length === 0 ? <p className="text-sm text-muted-foreground">Trash is empty.</p> : files.map((file) => <div key={file.id} className="flex items-center gap-2 border-b py-2">
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span><Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span>{!readOnly && <><Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>
<AlertDialog><AlertDialogTrigger asChild><Button size="sm" variant="ghost" disabled={disabled}>Purge</Button></AlertDialogTrigger>
<AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Permanently delete {file.path}?</AlertDialogTitle>
<AlertDialogDescription>This deleted copy and its deleted children will no longer be recoverable.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent></AlertDialog>
</AlertDialogContent></AlertDialog></>}
</div>)}</div> : <div className="grid min-h-0 gap-3 md:grid-cols-3">
<div className="max-h-96 overflow-auto rounded-md border"><FileTree nodes={nodes} selectedFile={selectedPath} expandedDirs={expanded}
onToggleDir={(filePath) => { setSelectedPath(filePath); setExpanded((before) => { const next = new Set(before); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); }}
onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} /></div>
onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: readOnly ? "No cached files have been saved for this scope." : "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} /></div>
<div className="flex min-h-0 flex-col gap-2 md:col-span-2">
{selected && <div className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-sm">{selected.path}</span>
{selected.kind === "file" && !exampleFiles && <Button asChild size="sm" variant="outline"><a href={workFoldersApi.downloadUrl(owner, selected.path)} download><Download aria-hidden />Download</a></Button>}
<Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button></div>}
{!readOnly && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button>}</div>}
{preview.isLoading ? <p className="text-sm text-muted-foreground">Loading preview</p> : preview.error ? <p role="alert" className="text-sm text-muted-foreground">{preview.error.message}</p> : preview.data ?
<div className="flex max-h-96 min-h-0 flex-col overflow-auto rounded-md border"><FileContentViewer content={preview.data} highlightedLine={null} /></div> : <p className="text-sm text-muted-foreground">Select a file to preview it.</p>}
</div>

View File

@ -1,3 +1,4 @@
import { CachedTaskFilesButton } from "@/components/CachedTaskFilesButton";
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
@ -2697,7 +2698,7 @@ export function IssueProperties({
</PropertyPicker>
</PropertySection>
{workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
{experimentalSettings?.enableCachedTaskFiles || workspacePickerEligible || hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
<PropertySection title="Workspace" streamlined={streamlinedPropertiesEnabled}>
{workspacePickerEligible ? (
<PropertyPicker
@ -2815,6 +2816,11 @@ export function IssueProperties({
)}
</PropertyPicker>
) : null}
{experimentalSettings?.enableCachedTaskFiles && (
<PropertyRow label="Files">
<CachedTaskFilesButton key={`${issue.id}:${issue.assigneeAgentId}:${issue.projectId}:${issue.responsibleUserId}:${currentUserId}`} issue={issue} currentUserId={currentUserId} />
</PropertyRow>
)}
{showWorkspaceDetailLink && issue.executionWorkspaceId && (
<PropertyRow label="Workspace">
<Link

View File

@ -89,6 +89,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enableStatusCards: false,
enableDecisions: false,
enableGoalsSidebarLink: false,
enableCachedTaskFiles: false,
enableServerInfoDebugView: false,
enablePaperclipDeveloperMode: false,
enableSimplifiedEnglishInteractions: false,
@ -182,6 +183,19 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
vi.clearAllMocks();
});
it("offers cached task file inspection under development settings, off by default", async () => {
await renderPage();
const toggle = container.querySelector<HTMLButtonElement>('section[aria-labelledby="developer-mode-heading"] button[aria-label="Allow viewing cached task files"]');
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(() => toggle!.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ enableCachedTaskFiles: true });
expect(toggle?.getAttribute("aria-checked")).toBe("true");
await act(() => toggle!.click());
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ enableCachedTaskFiles: false });
});
it("renders a page-level warning about instability and lack of guarantees", async () => {
await renderPage();

View File

@ -463,6 +463,16 @@ export function InstanceExperimentalSettings() {
</p>
</div>
<ExperimentalToggleCard
title="Allow viewing cached task files"
description="Show a cached-file inspector in task properties for the task, agent, responsible user, and project. Saved copies may lag behind the live sandbox filesystem."
checked={experimentalQuery.data?.enableCachedTaskFiles === true}
onCheckedChange={(checked) => toggleMutation.mutate({ enableCachedTaskFiles: checked })}
disabled={toggleMutation.isPending}
settingKey="enableCachedTaskFiles"
managed={managedKeys.enableCachedTaskFiles}
ariaLabel="Allow viewing cached task files"
/>
<ExperimentalToggleCard
title="Paperclip Developer Mode"
description="Show internal Paperclip maintainer tools and observability links, including Honeycomb trace queries on run pages."

View File

@ -5,25 +5,21 @@ The default URL is `http://localhost:6006`.
## Current pages
The 2026-09-08 design decision removes stored-file entry points from task, agent,
project, and profile pages. Saved copies can lag behind the running sandbox and
are not a complete view of what the agent sees on disk. No replacement Files
link is added to the properties pane.
**Work folders / Pages / Development Settings** shows the opt-in **Allow
viewing cached task files** toggle under Paperclip Developer Mode. It defaults
to off. **Task Page Cached Files** shows the enabled task properties: choose
**View cached files**, then Task, Project, Agent, or Responsible user to preview
and download their saved collections. The inspector is read-only and identifies
the data as cached, potentially behind the sandbox disk. User scope follows the
task's responsible user and remains private to that user.
**Work folders / Pages** mounts the actual pages inside `Layout`: Task Page,
Agent Page, Project Page, Profile Settings Page, and Mobile Task Page. These show
the current UI without the removed buttons. The previous open-dialog stories
have been removed.
## Deferred inspection features
Debug inspection of persisted files and live sandbox filesystem browsing need
separate designs. Persistence, checkpointing, and the runtime APIs remain in
place; this UI change does not remove saved data or alter synchronization.
Task Page and Mobile Task Page show the default, disabled state. Agent Page,
Project Page, and Profile Settings Page have no standalone stored-file buttons.
Live sandbox filesystem browsing remains a future feature.
**Work folders / Stored-file prototype** retains the reusable browser for design
reference only. It is explicitly labeled as unshipped and has no entry point in
the application or Design Guide. It includes Markdown, code, image and empty-file
reference only. Its editing controls are explicitly labeled as unshipped; the task inspector
uses the same browser in read-only mode. It includes Markdown, code, image and empty-file
previews, unsupported/large-file messages, loading, empty, saving, failed-save,
unavailable-storage, failed-upload, trash, and permanent-deletion states.
@ -35,13 +31,13 @@ the story or change its controls.
Prototype uploads, previews, downloads, folder creation, deletion, restoration,
purge, and refresh use fresh in-memory data. They do not contact a tenant, launch
agents, or persist files. Other page mutations display an unsupported-demo
agents, or persist files. The experimental toggle is also simulated in memory. Other page mutations display an unsupported-demo
message. The story restores its fetch handler and clears its query cache on
unmount. The loading example remains pending until you leave the story.
Saving and failed states are fixed visual examples. Refresh shows the real
acknowledgement but does not launch a sandbox. These prototypes do not replace
runtime persistence testing or implement either deferred inspection feature.
runtime persistence testing or implement live sandbox inspection.
## Supporting UI

View File

@ -1,3 +1,4 @@
import { instanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { useEffect, useState, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
@ -40,7 +41,7 @@ export const workFolderAgent = {
},
};
function seed(client: QueryClient) {
function seed(client: QueryClient, enableCachedTaskFiles: boolean) {
const company = WORK_FOLDER_COMPANY;
client.setQueryData(queryKeys.auth.session, storybookAuthSession);
client.setQueryData(queryKeys.liveRuns(company), []);
@ -126,6 +127,8 @@ function seed(client: QueryClient) {
keyboardShortcutsEnabled: false,
});
client.setQueryData(queryKeys.instance.experimentalSettings, {
...instanceExperimentalSettingsSchema.parse({}),
enableCachedTaskFiles,
enableIsolatedWorkspaces: true,
enableManagedSandboxOnly: true,
});
@ -140,9 +143,11 @@ function seed(client: QueryClient) {
/** Mounted only for work-folder stories; resets cache, mutations, and fetch handlers on exit. */
export function WorkFolderStoryProvider({
scenario = "saved",
enableCachedTaskFiles = false,
children,
}: {
scenario?: WorkFolderScenario;
enableCachedTaskFiles?: boolean;
children: ReactNode;
}) {
const [client] = useState(() => {
@ -152,7 +157,7 @@ export function WorkFolderStoryProvider({
mutations: { retry: false },
},
});
seed(value);
seed(value, enableCachedTaskFiles);
return value;
});
const [ready, setReady] = useState(false);
@ -170,6 +175,12 @@ export function WorkFolderStoryProvider({
const response = await fixture.handle(request);
if (response) return response;
const url = new URL(request.url);
if (url.pathname === "/api/instance/settings/experimental" && ["GET", "PATCH"].includes(request.method)) {
const previous = client.getQueryData(queryKeys.instance.experimentalSettings) ?? {};
const settings = request.method === "PATCH" ? { ...previous, ...await request.json() } : previous;
client.setQueryData(queryKeys.instance.experimentalSettings, settings);
return Response.json(settings);
}
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"))

View File

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { CachedTaskFilesButton } from "@/components/CachedTaskFilesButton";
import { WorkFolderStoryProvider, workFolderTask } from "../fixtures/WorkFolderStoryProvider";
const meta = {
title: "Work folders/Cached task inspector",
component: CachedTaskFilesButton,
args: { issue: workFolderTask, currentUserId: "user-board" },
decorators: [(Story) => <WorkFolderStoryProvider><Story /></WorkFolderStoryProvider>],
} satisfies Meta<typeof CachedTaskFilesButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BrowseCachedContext: Story = {
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(page.getByRole("button", { name: "View cached files" }));
await expect(page.getByRole("dialog", { name: "Cached task files" })).toBeVisible();
for (const scope of ["Task", "Project", "Agent", "Responsible user"]) {
await userEvent.click(page.getByRole("tab", { name: scope }));
await userEvent.click(await page.findByRole("treeitem", { name: "README.md" }));
await expect(page.getByRole("link", { name: "Download" })).toBeVisible();
await expect(page.queryByRole("button", { name: "Upload" })).not.toBeInTheDocument();
}
await userEvent.click(page.getByRole("tab", { name: "Task" }));
},
};
export const PrivateResponsibleUser: Story = {
args: { currentUserId: "another-user" },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(page.getByRole("button", { name: "View cached files" }));
await userEvent.click(page.getByRole("tab", { name: "Responsible user" }));
await expect(page.getByText("These cached files are private to the responsible user.")).toBeVisible();
await expect(page.queryByRole("tree")).not.toBeInTheDocument();
},
};
export const UnboundProject: Story = {
args: { issue: { ...workFolderTask, projectId: null } },
play: async ({ canvasElement }) => {
const page = within(canvasElement.ownerDocument.body);
await userEvent.click(page.getByRole("button", { name: "View cached files" }));
await userEvent.click(page.getByRole("tab", { name: "Project" }));
await expect(page.getByText("No project is bound to this task. This folder is empty and unbound.")).toBeVisible();
await expect(page.queryByRole("tree")).not.toBeInTheDocument();
},
};

View File

@ -6,6 +6,7 @@ import { Layout } from "@/components/Layout";
import { IssueDetail } from "@/pages/IssueDetail";
import { AgentDetail } from "@/pages/AgentDetail";
import { ProjectDetail } from "@/pages/ProjectDetail";
import { InstanceExperimentalSettings } from "@/pages/InstanceExperimentalSettings";
import { ProfileSettings } from "@/pages/ProfileSettings";
import { useCompany } from "@/context/CompanyContext";
import {
@ -17,13 +18,15 @@ import {
import { WORK_FOLDER_COMPANY } from "../fixtures/workFolders";
import type { WorkFolderScope } from "@paperclipai/shared";
type PageScope = WorkFolderScope | "settings";
const paths = {
settings: "/PAP/company/settings/instance/experimental",
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 }) {
function Page({ scope }: { scope: PageScope }) {
const { selectedCompanyId, setSelectedCompanyId } = useCompany();
const location = useLocation();
const navigate = useNavigate();
@ -40,6 +43,7 @@ function Page({ scope }: { scope: WorkFolderScope }) {
<PluginLauncherProvider>
<Routes>
<Route path="/:companyPrefix" element={<Layout />}>
<Route path="company/settings/instance/experimental" element={<InstanceExperimentalSettings />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="agents/:agentId/:tab?" element={<AgentDetail />} />
<Route path="projects/:projectId/:tab?" element={<ProjectDetail />} />
@ -59,18 +63,18 @@ const meta = {
docs: {
description: {
component:
"Current production pages after removing the stored-file entry points. Persisted copies do not represent the live sandbox filesystem. Debug inspection of saved files and live sandbox browsing are deferred features. Page mutations are not simulated.",
"Task properties expose cached-file inspection only when enabled in development settings. The inspector previews and downloads saved copies, not the live sandbox filesystem. Other page mutations are not simulated.",
},
},
},
args: { scope: "task" },
args: { scope: "task", enableCachedTaskFiles: false },
argTypes: { scope: { control: false } },
render: ({ scope }: { scope: WorkFolderScope }) => (
<WorkFolderStoryProvider key={scope}>
render: ({ scope, enableCachedTaskFiles }: { scope: PageScope; enableCachedTaskFiles: boolean }) => (
<WorkFolderStoryProvider key={`${scope}:${enableCachedTaskFiles}`} enableCachedTaskFiles={enableCachedTaskFiles}>
<Page scope={scope} />
</WorkFolderStoryProvider>
),
} satisfies Meta<{ scope: WorkFolderScope }>;
} satisfies Meta<{ scope: PageScope; enableCachedTaskFiles: boolean }>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TaskPage: Story = { args: { scope: "task" } };
@ -81,3 +85,6 @@ export const MobileTaskPage: Story = {
args: { scope: "task" },
globals: { viewport: { value: "mobile" } },
};
export const TaskPageCachedFiles: Story = { args: { scope: "task", enableCachedTaskFiles: true } };
export const DevelopmentSettings: Story = { args: { scope: "settings" } };

View File

@ -19,7 +19,7 @@ const meta = {
docs: {
description: {
component:
"Unshipped stored-file browser prototype. Application entry points were removed because saved copies are not the live sandbox filesystem. This Storybook-only review surface uses disposable in-memory data; debug inspection and live sandbox browsing need separate designs.",
"Editable stored-file browser prototype using disposable in-memory data. The experimental task inspector reuses this browser in read-only mode. Saved copies are not the live sandbox filesystem.",
},
},
},
@ -43,7 +43,7 @@ const meta = {
<WorkFolderStoryProvider key={`${scope}:${scenario}`} scenario={scenario}>
<div className="mx-auto max-w-5xl">
<p className="mb-4 text-sm text-muted-foreground">
Stored-file prototype not exposed in the app. These are saved
Editable stored-file prototype editing controls are not exposed in the app. These are saved
copies, not the live sandbox filesystem.
</p>
<WorkFolderBrowser owner={workFolderOwners[scope]} />