diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md
index 11118c4613..d177652188 100644
--- a/doc/sandbox-work-folders.md
+++ b/doc/sandbox-work-folders.md
@@ -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.
diff --git a/ui/src/components/CachedTaskFilesButton.test.tsx b/ui/src/components/CachedTaskFilesButton.test.tsx
index 937e7190ec..bbaaab7174 100644
--- a/ui/src/components/CachedTaskFilesButton.test.tsx
+++ b/ui/src/components/CachedTaskFilesButton.test.tsx
@@ -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 }) =>
-
{JSON.stringify({ owner, readOnly })}
,
+ WorkFolderBrowser: ({ owner, readOnly, allowTrashActions }: { owner: unknown; readOnly: boolean; allowTrashActions: boolean }) =>
+ {JSON.stringify({ owner, readOnly, allowTrashActions })}
,
}));
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");
});
diff --git a/ui/src/components/CachedTaskFilesButton.tsx b/ui/src/components/CachedTaskFilesButton.tsx
index fb32edcb72..6e674b314f 100644
--- a/ui/src/components/CachedTaskFilesButton.tsx
+++ b/ui/src/components/CachedTaskFilesButton.tsx
@@ -30,13 +30,13 @@ export function CachedTaskFilesButton({ issue, currentUserId }: { issue: TaskCon
{folders.map(({ scope, label, ownerId }) => (
- Cached copy of $HOME/{scope}/ · Preview and download only
+ Cached copy of $HOME/{scope}/ · Select files to move to trash; restore them from the Trash tab
{!ownerId ? (
No {label.toLowerCase()} is bound to this task. This folder is empty and unbound.
) : scope === "user" && ownerId !== currentUserId ? (
These cached files are private to the responsible user.
) : (
-
+
)}
))}
diff --git a/ui/src/components/WorkFolderBrowser.selection.test.tsx b/ui/src/components/WorkFolderBrowser.selection.test.tsx
new file mode 100644
index 0000000000..7f1904cf2d
--- /dev/null
+++ b/ui/src/components/WorkFolderBrowser.selection.test.tsx
@@ -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;
+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("button")].find((node) => node.textContent?.includes(text));
+const checkbox = (path: string) => container.querySelector(`[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( ));
+ 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]);
+ });
+});
diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx
index 5b0d278bdc..aca1db77e4 100644
--- a/ui/src/components/WorkFolderBrowser.tsx
+++ b/ui/src/components/WorkFolderBrowser.tsx
@@ -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());
+ const canManageTrash = !readOnly || allowTrashActions;
const [selectedPath, setSelectedPath] = useState(null);
const [expanded, setExpanded] = useState(new Set());
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
+ return
{ setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
{!readOnly && <>
fileInput.current?.click()}> Upload
{
const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = "";
if (chosen.length) mutation.mutate({ type: "upload", files: chosen });
}} />>}
-
{ setTrash(!trash); setSelectedPath(null); }}> {trash ? "Back to files" : "Trash"}
+
+ Files
+ Trash
+
+ {!trash && canManageTrash && checkedPaths.length > 0 &&
mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}> Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash }
{!readOnly &&
status.active)} onClick={() => mutation.mutate({ type: "refresh" })}> Refresh sandbox }
{saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"}
{lastSaved &&
Last agent save {new Date(lastSaved).toLocaleTimeString()} }
@@ -87,24 +100,35 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
{[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) =>
{(error as Error).message}
)}
{failed &&
{failed.error}
}
{announcement}
- {trash ?
{files.length === 0 ?
Trash is empty.
: files.map((file) =>
-
{file.path} {!readOnly && <>
mutation.mutate({ type: "restore", fileId: file.id })}> Restore
-
Purge…
+ {trash ? Deleted cached files are retained here. Restore them to return them to Files.
{files.length === 0 ? Trash is empty.
: files.map((file) =>
+
{file.path} {canManageTrash &&
mutation.mutate({ type: "restore", fileId: file.id })}> Restore }
+ {!readOnly &&
Purge…
Permanently delete {file.path}?
This deleted copy and its deleted children will no longer be recoverable.
Cancel mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete
- >}
-
)} :
+ }
+
)} :
{ 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`} />
{selected &&
{selected.path}
{selected.kind === "file" && !exampleFiles &&
Download}
- {!readOnly &&
mutation.mutate({ type: "delete", path: selected.path })}> Delete }
}
+
}
{preview.isLoading ? Loading preview…
: preview.error ? {preview.error.message}
: preview.data ?
: Select a file to preview it.
}
-
}
- ;
+ }
+ ;
}
diff --git a/ui/storybook/WORK-FOLDERS.md b/ui/storybook/WORK-FOLDERS.md
index 482548c96a..a8c181adb5 100644
--- a/ui/storybook/WORK-FOLDERS.md
+++ b/ui/storybook/WORK-FOLDERS.md
@@ -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.
diff --git a/ui/storybook/stories/work-folders.stories.tsx b/ui/storybook/stories/work-folders.stories.tsx
index 7183a88877..202879be44 100644
--- a/ui/storybook/stories/work-folders.stories.tsx
+++ b/ui/storybook/stories/work-folders.stories.tsx
@@ -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();
},
};