Merge branch 'codex/work-folders-staging-hardening-refresh' into codex/work-folders-remote-recovery-refresh

* codex/work-folders-staging-hardening-refresh:
  Include cached directories in selection and recursive trash operations
This commit is contained in:
Dotta 2026-09-11 15:16:05 -05:00
commit 2dea4702fd
5 changed files with 92 additions and 12 deletions

View File

@ -90,6 +90,18 @@ describe("FileTree", () => {
expect(row("docs")?.getAttribute("aria-checked")).toBe("mixed");
});
it.each([false, true])("includes empty directories in selection only when enabled (%s)", (includeDirectoriesInSelection) => {
act(() => {
root.render(<FileTree
nodes={[{ name: "empty", path: "empty", kind: "dir", children: [] }]}
selectedFile={null} expandedDirs={new Set()} checkedFiles={new Set(["empty"])}
onSelectFile={() => {}} onToggleDir={() => {}}
includeDirectoriesInSelection={includeDirectoriesInSelection}
/>);
});
expect((row("empty")?.querySelector("input") as HTMLInputElement).checked).toBe(includeDirectoriesInSelection);
});
it("renders file badges and host-only file extras", () => {
const nodes = buildFileTree({
"wiki/very-long-page-slug.md": "",

View File

@ -146,7 +146,7 @@ function flattenVisibleNodes(
return flattened;
}
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>, includeDirectories: boolean) {
if (node.kind === "file") {
return {
allChecked: checkedFiles.has(node.path),
@ -154,7 +154,7 @@ function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
};
}
const childFiles = collectAllPaths(node.children, "file");
const childFiles = includeDirectories ? collectAllPaths([node]) : collectAllPaths(node.children, "file");
const childFilePaths = [...childFiles];
const allChecked = childFilePaths.length > 0 && childFilePaths.every((p) => checkedFiles.has(p));
const someChecked = childFilePaths.some((p) => checkedFiles.has(p));
@ -250,6 +250,8 @@ export type FileTreeProps = {
/** @deprecated Use fileTones for public surfaces. Kept for compatibility with host-only callers. */
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
showCheckboxes?: boolean;
/** Include directories themselves, including empty ones, in selection state. */
includeDirectoriesInSelection?: boolean;
/** Allow long file and directory names to wrap instead of forcing horizontal overflow. */
wrapLabels?: boolean;
loading?: boolean;
@ -271,6 +273,7 @@ export function FileTree({
renderFileExtra,
fileRowClassName,
showCheckboxes = true,
includeDirectoriesInSelection = false,
wrapLabels = true,
loading = false,
error,
@ -395,7 +398,7 @@ export function FileTree({
<div aria-label={ariaLabel} role="tree">
{visibleNodes.map(({ node, depth }, index) => {
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles);
const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles, includeDirectoriesInSelection);
const badge = fileBadges?.[node.path];
const tone = fileTones?.[node.path] ?? "default";
const extraClassName = node.kind === "file" ? fileRowClassName?.(node, allChecked) : undefined;

View File

@ -34,8 +34,9 @@ beforeEach(async () => {
api.downloadUrl.mockReturnValue("/download");
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);
const matches = (file: typeof a) => file.path === operation.path || file.path.startsWith(`${operation.path}/`);
deleted.push(...active.filter(matches));
active = active.filter((file) => !matches(file));
} else {
active.push(...deleted.filter((file) => file.id === operation.fileId));
deleted = deleted.filter((file) => file.id !== operation.fileId);
@ -84,6 +85,39 @@ describe("cached file selection and retained trash", () => {
await settle();
expect(container.textContent).toContain("restored file contents");
});
it("moves an empty directory to trash when its checkbox is selected", async () => {
active = [{ id: "empty", path: "empty", kind: "directory" }];
await act(async () => { await client.invalidateQueries(); });
await settle();
await click(checkbox("empty"));
expect(checkbox("empty").checked).toBe(true);
await click(button("Move 1 item to trash")!);
expect(active).toEqual([]);
expect(deleted.map((file) => file.path)).toEqual(["empty"]);
});
it("deletes a selected directory once, including all its children", async () => {
active = [{ id: "dir", path: "docs", kind: "directory" }, { ...a, path: "docs/a.txt" }];
await act(async () => { await client.invalidateQueries(); });
await settle();
await click(checkbox("docs"));
await click(button("Move 1 item to trash")!);
expect(active).toEqual([]);
expect(api.operation).toHaveBeenCalledTimes(1);
expect(api.operation).toHaveBeenCalledWith(owner, { action: "delete", path: "docs" }, expect.any(String));
});
it("does not recursively delete a parent after a child is unchecked", async () => {
active = [{ id: "dir", path: "docs", kind: "directory" }, { ...a, path: "docs/a.txt" }, { ...b, path: "docs/b.txt" }];
await act(async () => { await client.invalidateQueries(); });
await settle();
await click(container.querySelector<HTMLElement>('[data-file-tree-path="docs"]')!);
await click(checkbox("docs"));
await click(checkbox("docs/a.txt"));
expect(checkbox("docs").checked).toBe(false);
expect(checkbox("docs").indeterminate).toBe(true);
await click(button("Move 1 file to trash")!);
expect(active.map((file) => file.path)).toEqual(["docs", "docs/a.txt"]);
expect(api.operation).toHaveBeenCalledWith(owner, { action: "delete", path: "docs/b.txt" }, expect.any(String));
});
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"));

View File

@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
import type { WorkFile, WorkFolderOwner } from "@paperclipai/shared";
import { workFoldersApi } from "@/api/work-folders";
import { FileTree, type FileTreeNode } from "@/components/FileTree";
import { FileTree, collectAllPaths, type FileTreeNode } from "@/components/FileTree";
import { FileContentViewer } from "@/components/FileViewerSheet";
import { Button } from "@/components/ui/button";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
@ -48,9 +48,21 @@ 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 availablePaths = collectAllPaths(nodes);
const checkedPaths = [...checkedFiles].filter((path) => availablePaths.has(path));
const checkedPathSet = new Set(checkedPaths);
const deletePaths = checkedPaths.filter((path) => {
let parent = path;
while (parent.includes("/")) {
parent = parent.slice(0, parent.lastIndexOf("/"));
if (checkedPathSet.has(parent)) return false;
}
return true;
});
const filePaths = new Set(files.filter((file) => file.kind === "file").map((file) => file.path));
const selectionLabel = deletePaths.every((path) => filePaths.has(path)) ? "file" : "item";
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: "deleteSelected"; paths: string[] } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
@ -60,8 +72,8 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
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);
setCheckedFiles((before) => new Set([...before].filter((candidate) => candidate !== path && !candidate.startsWith(`${path}/`))));
setSelectedPath((before) => before === path || before?.startsWith(`${path}/`) ? null : before);
}
}
else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID());
@ -98,7 +110,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
<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>}
{!trash && canManageTrash && deletePaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: deletePaths })}><Trash2 aria-hidden />Move {deletePaths.length} {selectionLabel}{deletePaths.length === 1 ? "" : "s"} 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">{saveLabel}</span>
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
@ -129,13 +141,16 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
</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)}
showCheckboxes={canManageTrash && !exampleFiles} checkedFiles={new Set(checkedPaths)} includeDirectoriesInSelection
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);
const paths = kind === "file" ? [path] : [...availablePaths].filter((candidate) => candidate === path || candidate.startsWith(`${path}/`));
setCheckedFiles((before) => {
const next = new Set(before);
const remove = paths.every((candidate) => before.has(candidate));
// A partial child selection must never leave its parent selected
// for a recursive delete.
for (const ancestor of before) if (path.startsWith(`${ancestor}/`)) next.delete(ancestor);
for (const candidate of paths) { if (remove) next.delete(candidate); else next.add(candidate); }
return next;
});

View File

@ -1,4 +1,5 @@
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { FileTree } from "@/components/FileTree";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
@ -474,6 +475,7 @@ function TaskExecutionControlsExample() {
}
export function DesignGuide() {
const [checkedFilePaths, setCheckedFilePaths] = useState(new Set<string>());
const [status, setStatus] = useState("todo");
const [priority, setPriority] = useState("medium");
const [selectValue, setSelectValue] = useState("in_progress");
@ -2306,6 +2308,20 @@ export function DesignGuide() {
</div>
</Section>
<Section title="File tree selection">
<p className="text-sm text-muted-foreground">Cached file browsers include folders in selection, including empty folders. Other file trees select files by default.</p>
<FileTree
nodes={[{ name: "empty", path: "empty", kind: "dir", children: [] }, { name: "notes.txt", path: "notes.txt", kind: "file", children: [] }]}
selectedFile={null} expandedDirs={new Set()} checkedFiles={checkedFilePaths}
onSelectFile={() => {}} onToggleDir={() => {}} includeDirectoriesInSelection
onToggleCheck={(path) => setCheckedFilePaths((before) => {
const next = new Set(before);
if (next.has(path)) next.delete(path); else next.add(path);
return next;
})}
/>
</Section>
<Section title="Built-in Agent Lifecycle Chips">
<p className="text-sm text-muted-foreground">
A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from