fix(ui): select cached files and browse retained trash in tabs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 10:01:39 -05:00
parent f65d0b841f
commit 630b002c75
7 changed files with 144 additions and 33 deletions

View File

@ -140,8 +140,11 @@ 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.
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.
The inspector clearly identifies saved copies that can lag behind agent edits.
Checkboxes select files; the move-to-trash action appears only for a nonempty
selection. Each scope has Files and Trash tabs, with restoration from retained
trash. Upload, folder creation, sandbox refresh, and permanent purge controls
remain outside this inspector. 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.

View File

@ -6,8 +6,8 @@ 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>,
WorkFolderBrowser: ({ owner, readOnly, allowTrashActions }: { owner: unknown; readOnly: boolean; allowTrashActions: boolean }) =>
<div data-testid="browser">{JSON.stringify({ owner, readOnly, allowTrashActions })}</div>,
}));
const issue = { id: "task-1", companyId: "company-1", projectId: "project-1", assigneeAgentId: "agent-1", responsibleUserId: "user-1" };
@ -29,11 +29,11 @@ async function select(label: string) {
await act(async () => { tab.focus(); });
}
describe("cached task context inspector", () => {
it("uses each task binding and exposes only inspection mode", async () => {
it("uses each task binding and allows trash actions without upload or refresh controls", 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(JSON.parse(document.querySelector('[data-testid="browser"]')!.textContent!)).toEqual({ owner: { companyId: "company-1", scope, ownerId }, readOnly: true, allowTrashActions: true });
}
expect(document.body.textContent).toContain("not the live sandbox filesystem");
});

View File

@ -30,13 +30,13 @@ export function CachedTaskFilesButton({ issue, currentUserId }: { issue: TaskCon
</TabsList>
{folders.map(({ scope, label, ownerId }) => (
<TabsContent key={scope} value={scope} className="flex min-h-0 flex-col gap-3 overflow-auto">
<p className="text-xs text-muted-foreground">Cached copy of $HOME/{scope}/ · Preview and download only</p>
<p className="text-xs text-muted-foreground">Cached copy of $HOME/{scope}/ · Select files to move to trash; restore them from the Trash tab</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 fillHeight />
<WorkFolderBrowser key={`${issue.companyId}:${scope}:${ownerId}`} owner={{ companyId: issue.companyId, scope, ownerId }} readOnly allowTrashActions fillHeight />
)}
</TabsContent>
))}

View File

@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { WorkFolderBrowser } from "./WorkFolderBrowser";
const api = vi.hoisted(() => ({ list: vi.fn(), sync: vi.fn(), operation: vi.fn() }));
vi.mock("@/api/work-folders", () => ({ workFoldersApi: api }));
const owner = { companyId: "company", scope: "task" as const, ownerId: "task" };
const a = { id: "a", path: "a.txt", kind: "file" };
const b = { id: "b", path: "b.txt", kind: "file" };
let active = [a, b];
let deleted: typeof active = [];
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
let client: QueryClient;
async function settle() {
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
}
async function click(element: HTMLElement) {
await act(async () => { element.focus(); element.click(); });
await settle();
}
const button = (text: string) => [...container.querySelectorAll<HTMLButtonElement>("button")].find((node) => node.textContent?.includes(text));
const checkbox = (path: string) => container.querySelector<HTMLInputElement>(`[data-file-tree-path="${path}"] input`)!;
beforeEach(async () => {
active = [a, b]; deleted = [];
api.list.mockImplementation(async (_owner, trash) => ({ files: [...(trash ? deleted : active)] }));
api.sync.mockResolvedValue([]);
api.operation.mockImplementation(async (_owner, operation) => {
if (operation.action === "delete") {
deleted.push(...active.filter((file) => file.path === operation.path));
active = active.filter((file) => file.path !== operation.path);
} else {
active.push(...deleted.filter((file) => file.id === operation.fileId));
deleted = deleted.filter((file) => file.id !== operation.fileId);
}
return { applied: true };
});
container = document.createElement("div"); document.body.append(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
root = createRoot(container);
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly allowTrashActions /></TooltipProvider></QueryClientProvider>));
await settle();
});
afterEach(async () => {
await act(async () => root.unmount()); client.clear(); container.remove(); vi.clearAllMocks();
});
describe("cached file selection and retained trash", () => {
it("only shows trash action for checked files and restores from the Trash tab", async () => {
expect(button("to trash")).toBeUndefined();
await click(checkbox("a.txt"));
expect(checkbox("a.txt").checked).toBe(true);
expect(button("Move 1 file to trash")).toBeDefined();
await click(button("Move 1 file to trash")!);
expect(active).toEqual([b]);
expect(button("to trash")).toBeUndefined();
await click(button("Trash")!);
expect(container.textContent).toContain("a.txt");
expect(button("Purge")).toBeUndefined();
await click(button("Restore")!);
await click(button("Files")!);
expect(checkbox("a.txt")).not.toBeNull();
expect(deleted).toEqual([]);
});
it("refreshes partial successes and leaves only failed files selected for retry", async () => {
const operate = api.operation.getMockImplementation()!;
api.operation.mockImplementation(async (target, operation) => {
if (operation.path === "b.txt") throw new Error("Storage unavailable");
return operate(target, operation);
});
await click(checkbox("a.txt")); await click(checkbox("b.txt"));
await click(button("Move 2 files to trash")!);
expect(checkbox("a.txt")).toBeNull();
expect(checkbox("b.txt").checked).toBe(true);
expect(container.textContent).toContain("Storage unavailable");
expect(button("Move 1 file to trash")).toBeDefined();
expect(deleted).toEqual([a]);
});
});

View File

@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
function tree(files: WorkFile[]) {
@ -30,9 +31,11 @@ function tree(files: WorkFile[]) {
sort(root.children); return root.children;
}
export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillHeight = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean; fillHeight?: boolean }) {
export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillHeight = false, allowTrashActions = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean; fillHeight?: boolean; allowTrashActions?: boolean }) {
const queryClient = useQueryClient();
const [trash, setTrash] = useState(false);
const [checkedFiles, setCheckedFiles] = useState(new Set<string>());
const canManageTrash = !readOnly || allowTrashActions;
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [expanded, setExpanded] = useState(new Set<string>());
const [directory, setDirectory] = useState("");
@ -44,21 +47,27 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
enabled: !exampleFiles, refetchInterval: 15_000, retry: false });
const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false });
const files = exampleFiles ?? filesQuery.data?.files ?? [];
const checkedPaths = files.filter((file) => file.kind === "file" && checkedFiles.has(file.path)).map((file) => file.path);
const selected = files.find((file) => file.path === selectedPath);
const nodes = useMemo(() => tree(files), [files]);
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.");
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "deleteSelected"; paths: string[] } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
if (readOnly && !(allowTrashActions && (action.type === "deleteSelected" || action.type === "restore"))) throw new Error("This cached file action is unavailable.");
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());
else if (action.type === "deleteSelected") {
for (const path of action.paths) {
await workFoldersApi.operation(owner, { action: "delete", path }, crypto.randomUUID());
setCheckedFiles((before) => { const next = new Set(before); next.delete(path); return next; });
setSelectedPath((before) => before === path ? null : before);
}
}
else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID());
else for (const run of (syncQuery.data ?? []).filter((run) => run.active)) await workFoldersApi.refresh(owner, run.runId);
}, onSuccess: async (_data, action) => {
setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved.");
await queryClient.invalidateQueries({ queryKey: key });
} });
}, onSettled: () => queryClient.invalidateQueries({ queryKey: key }) });
const statuses = syncQuery.data ?? [];
const failed = statuses.find((status) => status.state === "failed");
const saving = mutation.isPending || statuses.some((status) => status.state === "saving");
@ -67,14 +76,18 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
const lastOperation = filesQuery.data?.lastOperationAt;
const saveFailed = Boolean(failed) || mutation.isError;
const disabled = mutation.isPending || Boolean(exampleFiles);
return <div className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
return <Tabs value={trash ? "trash" : "files"} onValueChange={(value) => { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{!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>
<TabsList aria-label="Cached folder contents">
<TabsTrigger value="files">Files</TabsTrigger>
<TabsTrigger value="trash">Trash</TabsTrigger>
</TabsList>
{!trash && canManageTrash && checkedPaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}><Trash2 aria-hidden />Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash</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>}
@ -87,24 +100,35 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
{[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) => <p key={index} role="alert" className="text-sm text-destructive">{(error as Error).message}</p>)}
{failed && <p role="alert" className="text-sm text-destructive">{failed.error}</p>}
<p className="sr-only" aria-live="polite">{announcement}</p>
{trash ? <div className={cn("overflow-auto", fillHeight ? "min-h-0 flex-1" : "max-h-96")}>{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>{!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>
{trash ? <TabsContent value="trash" className={cn("overflow-auto", fillHeight ? "min-h-0 flex-1" : "max-h-96")}><p className="mb-3 text-sm text-muted-foreground">Deleted cached files are retained here. Restore them to return them to Files.</p>{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>{canManageTrash && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>}
{!readOnly && <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></>}
</div>)}</div> : <div className={cn("grid min-h-0 gap-3 md:grid-cols-3", fillHeight && "flex-1 grid-rows-2 md:grid-rows-1")}>
</AlertDialogContent></AlertDialog>}
</div>)}</TabsContent> : <TabsContent value="files" className={cn("grid min-h-0 gap-3 md:grid-cols-3", fillHeight && "flex-1 grid-rows-2 md:grid-rows-1")}>
<div className={cn("min-h-0 overflow-auto rounded-md border", !fillHeight && "max-h-96")}><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; }); }}
showCheckboxes={canManageTrash && !exampleFiles} checkedFiles={new Set(checkedPaths)}
onToggleCheck={(path, kind) => {
if (disabled) return;
const paths = kind === "file" ? [path] : files.filter((file) => file.kind === "file" && file.path.startsWith(`${path}/`)).map((file) => file.path);
setCheckedFiles((before) => {
const next = new Set(before);
const remove = paths.every((candidate) => before.has(candidate));
for (const candidate of paths) { if (remove) next.delete(candidate); else next.add(candidate); }
return next;
});
}}
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>}
{!readOnly && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button>}</div>}
</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={cn("flex min-h-0 flex-col overflow-auto rounded-md border", fillHeight ? "flex-1" : "max-h-96")}><FileContentViewer content={preview.data} highlightedLine={null} /></div> : <p className="text-sm text-muted-foreground">Select a file to preview it.</p>}
</div>
</div>}
</div>;
</TabsContent>}
</Tabs>;
}

View File

@ -9,8 +9,9 @@ The default URL is `http://localhost:6006`.
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
and download their saved collections. The inspector identifies the data as cached, potentially behind the sandbox disk.
Select checkboxes to move files to retained trash; restore them from the Trash
tab for that scope. User scope follows the
task's responsible user and remains private to that user.
Task Page and Mobile Task Page show the default, disabled state. Agent Page,
@ -19,7 +20,7 @@ Live sandbox filesystem browsing remains a future feature.
**Work folders / Stored-file prototype** retains the reusable browser for design
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
uses the same browser with selection, trash, and restore controls. 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.

View File

@ -19,7 +19,7 @@ const meta = {
docs: {
description: {
component:
"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.",
"Editable stored-file browser prototype using disposable in-memory data. The experimental task inspector reuses this browser with selection, trash, and restore controls. Saved copies are not the live sandbox filesystem.",
},
},
},
@ -113,14 +113,14 @@ export const UploadFailed: Story = {
export const Trash: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole("button", { name: "Trash" }),
await within(canvasElement).findByRole("tab", { 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("tab", { name: "Trash" }));
await userEvent.click(await c.findByRole("button", { name: "Purge…" }));
},
};
@ -142,11 +142,12 @@ export const UploadDeleteRestore: Story = {
}),
);
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" }));
await userEvent.click((await c.findByRole("treeitem", { name: "review.md" })).querySelector("input")!);
await userEvent.click(await c.findByRole("button", { name: "Move 1 file to trash" }));
await userEvent.click(c.getByRole("tab", { 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 userEvent.click(c.getByRole("tab", { name: "Files" }));
await expect(await c.findByText("review.md")).toBeVisible();
},
};