feat(ui): add experimental cached task file inspection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
71774a29ca
commit
427678bb5a
|
|
@ -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`:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 task’s 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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" } };
|
||||
|
|
|
|||
|
|
@ -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]} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue