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 && <> { const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ""; if (chosen.length) mutation.mutate({ type: "upload", files: chosen }); }} />} - + + Files + Trash + + {!trash && canManageTrash && checkedPaths.length > 0 && } {!readOnly && } {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 && <> - + {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 && } + {!readOnly && 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 && } - {!readOnly && }
} +
} {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(); }, };