diff --git a/docs/superpowers/plans/2026-08-10-workspace-selective-download.md b/docs/superpowers/plans/2026-08-10-workspace-selective-download.md new file mode 100644 index 0000000..a786399 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-workspace-selective-download.md @@ -0,0 +1,965 @@ +# Workspace Selective Download Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add tri-state checkbox multi-selection to the workspace Tree view and download the selected files/folders as one zip via an extended `/api/workspace/download` endpoint. + +**Architecture:** Client holds a collapsed `checked` set + an `excluded` set (pure module, no React) so folders can be checked without loading their subtrees. The existing download route gains repeated `include`/`exclude` query params; includes replace the `.` argument to the spawned `zip`, excludes become extra `-x` patterns (3 per exclude: `rel`, `rel/`, `rel/*`), with glob metacharacters backslash-escaped. A server-side pre-walk rejects selections that would produce a files-empty archive (zip exits 0 with a dirs-only archive in that case — verified experimentally). + +**Tech Stack:** Next.js App Router route handlers, React 19, Vitest, system `zip`/`unzip`. + +**Spec:** `docs/superpowers/specs/2026-08-09-workspace-selective-download-design.md` + +## Global Constraints + +- Test runner: `npm test` (vitest run). Single file: `npx vitest run `. +- Commit style (from `git log`): `feat(webui): …` / `fix(webui): …` / `test(webui): …`. +- Server route tests must realpath the temp workspace dir (macOS `/var` → `/private/var`) before use — `safeResolve` compares canonical paths. +- `zip -x` patterns are globs: `*`, `?`, `[`, `]`, `\` must be backslash-escaped when matching literal filenames. +- Do not break the existing no-`include` behavior (whole-workspace `workspace.zip`). + +--- + +### Task 1: Selection state module (`src/lib/workspaceSelection.ts`) + +Pure, dependency-free module modeling the tri-state selection. All later UI work consumes these exact functions. + +**Files:** +- Create: `src/lib/workspaceSelection.ts` +- Test: `src/lib/workspaceSelection.test.ts` + +**Interfaces:** +- Produces (consumed by Task 3): + - `interface WorkspaceSelection { readonly checked: ReadonlySet; readonly excluded: ReadonlySet }` + - `emptySelection(): WorkspaceSelection` + - `isChecked(sel: WorkspaceSelection, path: string): boolean` + - `isIndeterminate(sel: WorkspaceSelection, dir: string): boolean` + - `check(sel: WorkspaceSelection, path: string): WorkspaceSelection` + - `uncheck(sel: WorkspaceSelection, path: string): WorkspaceSelection` + - `toParams(sel: WorkspaceSelection): { include: string[]; exclude: string[] }` — both arrays sorted + +Semantics (from the spec): +- `checked` is **collapsed**: no entry is a descendant of another. Checking a folder stores only the folder and drops its descendants. +- `excluded` holds paths explicitly unchecked underneath a collapsed checked ancestor. +- `isChecked(path)` = covered by `checked` and not covered by `excluded`. +- `isIndeterminate(dir)` = dir not fully checked, but has a `checked` or `excluded` descendant. + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/workspaceSelection.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { + check, + emptySelection, + isChecked, + isIndeterminate, + toParams, + uncheck, +} from "./workspaceSelection"; + +describe("workspaceSelection", () => { + it("checks a single file", () => { + const sel = check(emptySelection(), "a.txt"); + expect(isChecked(sel, "a.txt")).toBe(true); + expect(isChecked(sel, "b.txt")).toBe(false); + }); + + it("collapses descendants when a folder is checked", () => { + let sel = check(emptySelection(), "dir/a.txt"); + sel = check(sel, "dir/b.txt"); + sel = check(sel, "dir"); + expect([...sel.checked]).toEqual(["dir"]); + expect(isChecked(sel, "dir/a.txt")).toBe(true); + expect(isChecked(sel, "dir/deep/c.txt")).toBe(true); + expect(isIndeterminate(sel, "dir")).toBe(false); + }); + + it("records an exclusion when a child of a checked folder is unchecked", () => { + let sel = check(emptySelection(), "dir"); + sel = uncheck(sel, "dir/a.txt"); + expect(isChecked(sel, "dir")).toBe(false); + expect(isChecked(sel, "dir/a.txt")).toBe(false); + expect(isChecked(sel, "dir/b.txt")).toBe(true); + expect(isIndeterminate(sel, "dir")).toBe(true); + }); + + it("excludes a whole subfolder and everything beneath it", () => { + let sel = check(emptySelection(), "dir"); + sel = uncheck(sel, "dir/sub"); + expect(isChecked(sel, "dir/sub")).toBe(false); + expect(isChecked(sel, "dir/sub/c.txt")).toBe(false); + expect(isChecked(sel, "dir/a.txt")).toBe(true); + }); + + it("re-checking an excluded child clears the exclusion", () => { + let sel = check(emptySelection(), "dir"); + sel = uncheck(sel, "dir/a.txt"); + sel = check(sel, "dir/a.txt"); + expect([...sel.excluded]).toEqual([]); + expect(isChecked(sel, "dir")).toBe(true); + expect(isIndeterminate(sel, "dir")).toBe(false); + }); + + it("unchecking a top-level folder drops its exclusions too", () => { + let sel = check(emptySelection(), "dir"); + sel = uncheck(sel, "dir/a.txt"); + sel = uncheck(sel, "dir"); + expect([...sel.checked]).toEqual([]); + expect([...sel.excluded]).toEqual([]); + expect(isChecked(sel, "dir/a.txt")).toBe(false); + }); + + it("checking a child of a checked-but-excluded path keeps the collapse", () => { + let sel = check(emptySelection(), "dir"); + sel = uncheck(sel, "dir/sub"); + sel = check(sel, "dir/sub/c.txt"); + expect([...sel.checked].sort()).toEqual(["dir"]); + expect([...sel.excluded]).toEqual(["dir/sub"]); + expect(isChecked(sel, "dir/sub/c.txt")).toBe(false); + }); + + it("is a no-op when unchecking something not selected", () => { + const sel = check(emptySelection(), "dir"); + expect(uncheck(sel, "other.txt")).toBe(sel); + }); + + it("produces sorted include/exclude params", () => { + let sel = check(emptySelection(), "z.txt"); + sel = check(sel, "dir"); + sel = uncheck(sel, "dir/b.txt"); + sel = uncheck(sel, "dir/a.txt"); + expect(toParams(sel)).toEqual({ + include: ["dir", "z.txt"], + exclude: ["dir/a.txt", "dir/b.txt"], + }); + }); +}); +``` + +Note on the 7th test: checking a path whose exclusion lives on a strict *ancestor* (`dir/sub` is excluded; user checks `dir/sub/c.txt`) is a no-op — only a direct `excluded` entry on the path itself can be re-checked away. This matches standard file-manager behavior (a freshly checked child under an excluded folder stays excluded). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/workspaceSelection.test.ts` +Expected: FAIL — `Cannot find module './workspaceSelection'` + +- [ ] **Step 3: Implement the module** + +Create `src/lib/workspaceSelection.ts`: + +```ts +// Tri-state checkbox selection for the workspace tree, modeled as a collapsed +// `checked` set (checking a folder stores only the folder, never its +// descendants) plus an `excluded` set for paths unchecked underneath a +// collapsed ancestor. This lets users select/unselect inside unloaded +// subtrees — the server expands folders at zip time. + +export interface WorkspaceSelection { + readonly checked: ReadonlySet; + readonly excluded: ReadonlySet; +} + +export function emptySelection(): WorkspaceSelection { + return { checked: new Set(), excluded: new Set() }; +} + +function isUnder(path: string, ancestor: string): boolean { + return path.startsWith(ancestor + "/"); +} + +function coveredBy(set: ReadonlySet, path: string): boolean { + for (const entry of set) { + if (entry === path || isUnder(path, entry)) return true; + } + return false; +} + +function hasDescendant(set: ReadonlySet, dir: string): boolean { + for (const entry of set) { + if (isUnder(entry, dir)) return true; + } + return false; +} + +/** New set without `path` itself or any of its descendants. */ +function withoutSubtree(set: ReadonlySet, path: string): Set { + const next = new Set(); + for (const entry of set) { + if (entry !== path && !isUnder(entry, path)) next.add(entry); + } + return next; +} + +export function isChecked(sel: WorkspaceSelection, path: string): boolean { + return coveredBy(sel.checked, path) && !coveredBy(sel.excluded, path); +} + +export function isIndeterminate(sel: WorkspaceSelection, dir: string): boolean { + if (isChecked(sel, dir)) return false; + return hasDescendant(sel.checked, dir) || hasDescendant(sel.excluded, dir); +} + +export function check( + sel: WorkspaceSelection, + path: string +): WorkspaceSelection { + if (isChecked(sel, path)) return sel; + if (coveredBy(sel.checked, path)) { + // Covered by a collapsed ancestor but excluded. Only a DIRECT exclusion + // entry on `path` itself can be cleared by re-checking; an exclusion on a + // strict ancestor (e.g. `dir/sub` while checking `dir/sub/c.txt`) keeps + // the path excluded. + if (!sel.excluded.has(path)) return sel; + return { + checked: sel.checked, + excluded: withoutSubtree(sel.excluded, path), + }; + } + const checked = withoutSubtree(sel.checked, path); + checked.add(path); + return { checked, excluded: withoutSubtree(sel.excluded, path) }; +} + +export function uncheck( + sel: WorkspaceSelection, + path: string +): WorkspaceSelection { + if (sel.checked.has(path)) { + return { + checked: withoutSubtree(sel.checked, path), + excluded: withoutSubtree(sel.excluded, path), + }; + } + if (isChecked(sel, path)) { + // Inside a collapsed checked folder: record an exclusion. + const excluded = withoutSubtree(sel.excluded, path); + excluded.add(path); + return { checked: sel.checked, excluded }; + } + return sel; +} + +export function toParams(sel: WorkspaceSelection): { + include: string[]; + exclude: string[]; +} { + return { + include: [...sel.checked].sort(), + exclude: [...sel.excluded].sort(), + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/workspaceSelection.test.ts` +Expected: 9 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/workspaceSelection.ts src/lib/workspaceSelection.test.ts +git commit -m "feat(webui): tri-state workspace selection state module" +``` + +--- + +### Task 2: Selective download in `GET /api/workspace/download` + +Extend the existing route with repeated `include`/`exclude` query params. No params → byte-identical existing behavior. + +**Files:** +- Modify: `src/app/api/workspace/download/route.ts` +- Test: `src/app/api/workspace/download/route.test.ts` (new) + +**Interfaces:** +- Consumes: `safeResolve`, `resolveInside`, `isHiddenEntry`, `zipExcludeArgs`, `isCrossOrigin` from `@/lib/server/workspace`; `resolveConversationWorkspace`, `ConversationWorkspaceError` from `@/lib/server/conversationWorkspace`. +- Produces (consumed by Task 3): `GET /api/workspace/download?threadId=&include=&exclude=` (repeatable params) → `application/zip` attachment. Errors: 400 JSON `{ error }`. Archive filename: single include → `.zip`; multiple → `workspace-selection.zip`; none → `workspace.zip`. + +Verified `zip` behaviors this design relies on (confirmed by experiment on the target machine): +- `zip -r out.zip dir -x dir/b.txt` excludes that one file. +- Excluding a directory needs three patterns: `dir/sub`, `dir/sub/`, `dir/sub/*`. +- `zip -x 'data/we\[ird\].txt'` matches the literal filename (backslash escaping works). +- When every file is excluded, zip exits **0** with a dirs-only archive — hence the server-side `selectionHasFiles` pre-check instead of relying on exit code 12. + +- [ ] **Step 1: Write the failing tests** + +Create `src/app/api/workspace/download/route.test.ts`: + +```ts +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { execFileSync } from "child_process"; +import { randomUUID } from "crypto"; +import { promises as fs } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { NextRequest } from "next/server"; + +vi.mock("server-only", () => ({})); + +// Canonicalized: safeResolve requires a realpath'd workspaceDir (macOS +// /var -> /private/var would otherwise fail its containment check). +const filesDir = await (async () => { + const dir = join(tmpdir(), `evosci-download-route-test-${process.pid}`); + await fs.mkdir(dir, { recursive: true }); + return fs.realpath(dir); +})(); + +vi.mock("@/lib/server/conversationWorkspace", () => ({ + ConversationWorkspaceError: class ConversationWorkspaceError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + } + }, + resolveConversationWorkspace: async () => ({ + deployment: {}, + threadId: "thread-1", + scopeId: "scope-1", + filesDir, + runtimeDir: filesDir, + }), +})); + +const routes = await import("./route"); + +function get(query = "") { + const qs = query ? `&${query}` : ""; + return routes.GET( + new NextRequest( + `http://localhost/api/workspace/download?threadId=thread-1${qs}` + ) + ); +} + +async function zipEntries(res: Response): Promise { + expect(res.status).toBe(200); + const tmp = join(tmpdir(), `evosci-dl-test-${randomUUID()}.zip`); + await fs.writeFile(tmp, Buffer.from(await res.arrayBuffer())); + try { + const out = execFileSync("unzip", ["-Z1", tmp], { encoding: "utf-8" }); + return out.split("\n").filter(Boolean).sort(); + } finally { + await fs.rm(tmp, { force: true }); + } +} + +afterAll(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); +}); + +describe("GET /api/workspace/download", () => { + beforeEach(async () => { + await fs.rm(filesDir, { recursive: true, force: true }); + await fs.mkdir(join(filesDir, "dir", "sub"), { recursive: true }); + await fs.mkdir(join(filesDir, "data"), { recursive: true }); + await fs.writeFile(join(filesDir, "dir", "a.txt"), "A"); + await fs.writeFile(join(filesDir, "dir", "b.txt"), "B"); + await fs.writeFile(join(filesDir, "dir", "sub", "c.txt"), "C"); + await fs.writeFile(join(filesDir, "data", "we[ird].txt"), "W"); + await fs.writeFile(join(filesDir, "data", "ok.txt"), "O"); + await fs.writeFile(join(filesDir, "notes.md"), "# hi"); + await fs.writeFile(join(filesDir, ".hidden"), "secret"); + }); + + it("zips the whole workspace when no include is given (regression)", async () => { + const res = await get(); + expect(res.headers.get("content-disposition")).toContain("workspace.zip"); + const entries = await zipEntries(res); + expect(entries).toContain("notes.md"); + expect(entries).toContain("dir/a.txt"); + expect(entries).not.toContain(".hidden"); + }); + + it("zips a single included folder and names the archive after it", async () => { + const res = await get("include=dir"); + expect(res.headers.get("content-disposition")).toContain("dir.zip"); + const entries = await zipEntries(res); + expect(entries).toEqual([ + "dir/", + "dir/a.txt", + "dir/b.txt", + "dir/sub/", + "dir/sub/c.txt", + ]); + }); + + it("excludes a single file inside an included folder", async () => { + const res = await get("include=dir&exclude=dir/b.txt"); + const entries = await zipEntries(res); + expect(entries).toEqual([ + "dir/", + "dir/a.txt", + "dir/sub/", + "dir/sub/c.txt", + ]); + }); + + it("excludes a whole subdirectory", async () => { + const res = await get("include=dir&exclude=dir/sub"); + const entries = await zipEntries(res); + expect(entries).toEqual(["dir/", "dir/a.txt", "dir/b.txt"]); + }); + + it("supports multiple includes with a generic archive name", async () => { + const res = await get("include=dir/sub&include=notes.md"); + expect(res.headers.get("content-disposition")).toContain( + "workspace-selection.zip" + ); + const entries = await zipEntries(res); + expect(entries).toEqual(["dir/sub/", "dir/sub/c.txt", "notes.md"]); + }); + + it("matches glob metacharacters in exclude paths literally", async () => { + const res = await get( + `include=data&exclude=${encodeURIComponent("data/we[ird].txt")}` + ); + const entries = await zipEntries(res); + expect(entries).toEqual(["data/", "data/ok.txt"]); + }); + + it("rejects path traversal in include", async () => { + const res = await get(`include=${encodeURIComponent("../outside")}`); + expect(res.status).toBe(400); + }); + + it("rejects hidden entries in include", async () => { + const res = await get("include=.hidden"); + expect(res.status).toBe(400); + }); + + it("rejects an exclude outside all includes", async () => { + const res = await get("include=dir&exclude=notes.md"); + expect(res.status).toBe(400); + }); + + it("rejects exclude without any include", async () => { + const res = await get("exclude=dir/a.txt"); + expect(res.status).toBe(400); + }); + + it("rejects a selection that is empty after excludes", async () => { + const res = await get( + "include=dir&exclude=dir/a.txt&exclude=dir/b.txt&exclude=dir/sub" + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("The selection is empty."); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run src/app/api/workspace/download/route.test.ts` +Expected: FAIL — the selective tests get a full-workspace zip (or pass trivially for regression); specifically "zips a single included folder" fails because `include` is ignored today. + +- [ ] **Step 3: Implement the route changes** + +Replace the entire contents of `src/app/api/workspace/download/route.ts` with: + +```ts +import { createReadStream, promises as fs } from "fs"; +import { tmpdir } from "os"; +import { basename, join, relative, sep } from "path"; +import { randomUUID } from "crypto"; +import { spawn } from "child_process"; +import { Readable } from "stream"; +import { NextRequest, NextResponse } from "next/server"; +import { + zipExcludeArgs, + isCrossOrigin, + isHiddenEntry, + resolveInside, + safeResolve, +} from "@/lib/server/workspace"; +import { + ConversationWorkspaceError, + resolveConversationWorkspace, +} from "@/lib/server/conversationWorkspace"; + +export const runtime = "nodejs"; + +/** Zip `includePaths` (relative to `workspaceDir`) into `outFile` using the OS + * `zip`. Aborts (and kills the child) if `signal` fires — e.g. the client + * disconnects mid-archive. */ +function zipWorkspace( + workspaceDir: string, + outFile: string, + includePaths: string[], + extraExcludeArgs: string[], + signal?: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + // -r recurse, -q quiet, -X drop extra file attributes, -y store symlinks AS + // symlinks instead of dereferencing them (so a symlink pointing outside the + // workspace can't pull external file *contents* into the archive). + // Exclusions come from the shared ignore lists so the archive matches the + // tree exactly (dotfiles, large_tool_results/conversation_history, build noise). + const child = spawn( + "zip", + [ + "-r", + "-q", + "-X", + "-y", + outFile, + ...includePaths, + ...extraExcludeArgs, + ...zipExcludeArgs(), + ], + { cwd: workspaceDir } + ); + + const onAbort = () => child.kill("SIGKILL"); + if (signal) { + if (signal.aborted) { + child.kill("SIGKILL"); + reject(new Error("Request aborted.")); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + let stderr = ""; + child.stderr.on("data", (d) => (stderr += d.toString())); + child.on("error", (err) => { + signal?.removeEventListener("abort", onAbort); + reject( + (err as NodeJS.ErrnoException).code === "ENOENT" + ? new Error( + "The `zip` command is not available on this system, so the workspace can't be downloaded as an archive." + ) + : err + ); + }); + child.on("close", (code) => { + signal?.removeEventListener("abort", onAbort); + if (signal?.aborted) reject(new Error("Request aborted.")); + // 12 = "nothing to do" — unreachable for selections (the pre-check below + // rejects empty selections up front) unless every include vanished + // between the pre-check and the spawn; the whole-workspace case is a + // friendly error either way. + else if (code === 12) reject(new Error("The workspace is empty.")); + else if (code !== 0) + reject(new Error(stderr.trim() || `zip exited with code ${code}`)); + else resolve(); + }); + }); +} + +/** Escape zip's glob metacharacters so an -x pattern matches one exact path. */ +function escapeZipPattern(path: string): string { + return path.replace(/[\\*?[\]]/g, "\\$&"); +} + +/** -x args excluding one relative path. A file exclude matches `rel`; a dir + * exclude must also drop the dir entry itself (`rel/`) and everything + * beneath it (`rel/*`) — zip matches patterns against archive member names. */ +function excludeArgsFor(rel: string): string[] { + const esc = escapeZipPattern(rel); + return ["-x", esc, "-x", `${esc}/`, "-x", `${esc}/*`]; +} + +/** Keep only topmost paths: drop duplicates and entries shadowed by an + * already-listed ancestor (sorting guarantees ancestors come first). */ +function collapseTopmost(rels: string[]): string[] { + const sorted = [...new Set(rels)].sort(); + const out: string[] = []; + for (const rel of sorted) { + if (!out.some((top) => rel.startsWith(top + "/"))) out.push(rel); + } + return out; +} + +/** True if the includes still contain at least one file/symlink after the + * hidden-entry policy and the excludes are applied. Runs before spawning + * `zip`, which happily produces a dirs-only archive (exit 0) when every file + * was excluded — so exit codes can't detect an empty selection. */ +async function selectionHasFiles( + workspaceDir: string, + includes: string[], + excludes: string[] +): Promise { + const isExcluded = (rel: string) => + excludes.some((e) => rel === e || rel.startsWith(e + "/")); + const walk = async (rel: string): Promise => { + if (isExcluded(rel)) return false; + let stat; + try { + stat = await fs.lstat(join(workspaceDir, rel)); + } catch { + return false; // vanished between listing and download + } + if (!stat.isDirectory()) return true; + const entries = await fs.readdir(join(workspaceDir, rel)); + for (const name of entries) { + if (isHiddenEntry(name)) continue; + if (await walk(rel === "" ? name : `${rel}/${name}`)) return true; + } + return false; + }; + for (const rel of includes) { + if (rel === ".") return true; // whole-workspace mode: zip handles emptiness + if (await walk(rel)) return true; + } + return false; +} + +/** Content-Disposition for an attachment whose name may be non-ASCII. */ +function attachmentDisposition(filename: string): string { + // eslint-disable-next-line no-control-regex + const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`; +} + +export async function GET(request: NextRequest) { + let tmpFile: string | null = null; + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { error: "Cross-origin workspace access is not allowed." }, + { status: 403 } + ); + } + + const { filesDir: workspaceDir } = await resolveConversationWorkspace( + request + ); + + const includes = request.nextUrl.searchParams + .getAll("include") + .filter((p) => p.trim() !== ""); + const excludes = request.nextUrl.searchParams + .getAll("exclude") + .filter((p) => p.trim() !== ""); + + // No includes → the original whole-workspace archive. + let includeRels: string[] = ["."]; + let excludeRels: string[] = []; + + if (includes.length === 0) { + if (excludes.length > 0) { + return NextResponse.json( + { error: "exclude requires at least one include." }, + { status: 400 } + ); + } + } else { + // Resolve every include to a canonical in-workspace relative path + // (safeResolve rejects traversal, hidden entries, symlink escapes, and + // paths that don't exist). + const rels: string[] = []; + for (const p of includes) { + const target = await safeResolve(workspaceDir, p); + rels.push(relative(workspaceDir, target).split(sep).join("/")); + } + includeRels = rels.includes("") ? ["."] : collapseTopmost(rels); + + // Excludes only filter what the includes pulled in, so they don't need + // to exist (the entry may be gone by now) — a lexical check suffices. + // Each must sit strictly inside at least one include. + for (const p of excludes) { + const target = resolveInside(workspaceDir, p); + const rel = relative(workspaceDir, target).split(sep).join("/"); + const inside = includeRels.some( + (inc) => inc === "." || (rel !== "" && rel.startsWith(inc + "/")) + ); + if (!inside) { + return NextResponse.json( + { error: `exclude "${p}" is not inside any selected include.` }, + { status: 400 } + ); + } + excludeRels.push(rel); + } + excludeRels = [...new Set(excludeRels)].sort(); + + if ( + includeRels[0] !== "." && + !(await selectionHasFiles(workspaceDir, includeRels, excludeRels)) + ) { + return NextResponse.json( + { error: "The selection is empty." }, + { status: 400 } + ); + } + } + + tmpFile = join(tmpdir(), `evoscientist-workspace-${randomUUID()}.zip`); + const extraExcludeArgs = excludeRels.flatMap(excludeArgsFor); + await zipWorkspace( + workspaceDir, + tmpFile, + includeRels, + extraExcludeArgs, + request.signal + ); + + const stat = await fs.stat(tmpFile); + const nodeStream = createReadStream(tmpFile); + // Delete the temp archive once the response has been fully read (or the + // client disconnects) — `close` fires in both cases. + const cleanup = tmpFile; + nodeStream.on("close", () => void fs.rm(cleanup, { force: true })); + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + + const zipName = + includes.length === 0 + ? "workspace.zip" + : includeRels.length === 1 && includeRels[0] !== "." + ? `${basename(includeRels[0])}.zip` + : "workspace-selection.zip"; + + return new NextResponse(webStream, { + headers: { + "Content-Type": "application/zip", + "Content-Length": String(stat.size), + "Content-Disposition": attachmentDisposition(zipName), + "Cache-Control": "no-store", + }, + }); + } catch (error) { + if (tmpFile) await fs.rm(tmpFile, { force: true }).catch(() => {}); + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to package the workspace.", + }, + { + status: + error instanceof ConversationWorkspaceError ? error.status : 400, + } + ); + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run src/app/api/workspace/download/route.test.ts` +Expected: 11 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/workspace/download/route.ts src/app/api/workspace/download/route.test.ts +git commit -m "feat(webui): selective include/exclude download for workspace zips" +``` + +--- + +### Task 3: Tree-view checkboxes + "Selected (N)" download in `WorkspacePanel` + +**Files:** +- Modify: `src/app/components/WorkspacePanel.tsx` + +**Interfaces:** +- Consumes (from Task 1): `WorkspaceSelection`, `emptySelection`, `isChecked`, `isIndeterminate`, `check`, `uncheck`, `toParams`. +- Consumes (from Task 2): `GET /api/workspace/download?threadId=…&include=…&exclude=…`. + +- [ ] **Step 1: Wire selection state into the component** + +In `src/app/components/WorkspacePanel.tsx`: + +a) Add the import at the top with the other imports: + +```ts +import { + check as checkSelection, + emptySelection, + isChecked as isSelectionChecked, + isIndeterminate as isSelectionIndeterminate, + uncheck as uncheckSelection, + toParams as selectionToParams, + type WorkspaceSelection, +} from "@/lib/workspaceSelection"; +``` + +b) Add state next to the other tree-view state (after the `selected` state, ~line 185): + +```ts + const [selection, setSelection] = useState( + emptySelection() + ); + const toggleSelection = useCallback((path: string) => { + setSelection((prev) => + isSelectionChecked(prev, path) + ? uncheckSelection(prev, path) + : checkSelection(prev, path) + ); + }, []); +``` + +c) Reset the selection when the conversation changes — inside the existing `useEffect` that resets `setChildren({})` etc. (~line 241), add one line: + +```ts + setSelection(emptySelection()); +``` + +d) Build the download href (place near the `grouped` memo, ~line 332): + +```ts + const selectionCount = selection.checked.size; + // Native download so large archives stream straight to disk. The + // URL-length guard keeps a huge selection from hitting server/header limits + // — the fix is to select fewer, higher-level folders instead. + const selectionDownload = useMemo<{ + href: string | null; + tooLarge: boolean; + }>(() => { + if (!threadId || selectionCount === 0) + return { href: null, tooLarge: false }; + const { include, exclude } = selectionToParams(selection); + const params = new URLSearchParams({ threadId }); + for (const p of include) params.append("include", p); + for (const p of exclude) params.append("exclude", p); + const href = `/api/workspace/download?${params}`; + return href.length <= 7000 + ? { href, tooLarge: false } + : { href: null, tooLarge: true }; + }, [threadId, selection, selectionCount]); +``` + +- [ ] **Step 2: Add the checkbox to tree rows** + +In `renderEntries` (~line 346), the row is currently a single ` + + {entry.type === "dir" && + isOpen && + renderEntries(entry.path, depth + 1)} + + ); + }); +``` + +Notes: +- The hover background moved to the wrapper div so checkbox + label highlight together. +- The depth padding moved from the button to the wrapper; the button keeps `min-w-0 flex-1` so long names still truncate. +- `indeterminate` is only settable via JS, hence the `ref` callback. +- Clicking an indeterminate folder's checkbox fires `onChange` → `toggleSelection` → `check` (fully selects it), the standard file-manager behavior. + +- [ ] **Step 3: Add the "Selected (N)" toolbar button** + +In the toolbar (~line 471, the div wrapping the **All** link and refresh button), insert this immediately before the existing **All** anchor: + +```tsx + e.preventDefault() + } + className={cn( + "inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs transition-colors", + selectionDownload.href + ? "text-muted-foreground hover:bg-muted hover:text-foreground" + : "cursor-not-allowed text-muted-foreground/50" + )} + title={ + selectionDownload.tooLarge + ? "Selection is too large to download — select fewer, higher-level folders" + : selectionCount === 0 + ? "Select files or folders to download" + : "Download the selected items as a zip" + } + > + + Selected{selectionCount > 0 ? ` (${selectionCount})` : ""} + +``` + +- [ ] **Step 4: Typecheck, lint, and run the full suite** + +Run: `npx tsc --noEmit && npx eslint src/app/components/WorkspacePanel.tsx src/lib/workspaceSelection.ts src/app/api/workspace/download/route.ts && npm test` +Expected: no type errors, no lint errors, all tests PASS (including the 20 new ones from Tasks 1–2). + +- [ ] **Step 5: Manual browser verification** + +Run: `npm run dev` (port 4716), then in the browser with a conversation that has nested workspace files: + +1. Tree view shows a checkbox on every row; checking a folder checks all visible descendants. +2. Uncheck one file inside a checked folder → folder shows the indeterminate dash. +3. Expand a *different* folder, check it, then click **Selected (2)** → browser downloads `workspace-selection.zip`. Then reduce to exactly one top-level selection → the archive is named `.zip`. Verify both. +4. Unzip the archive and confirm excluded files are absent and hidden/internal entries (`.langgraph_api`, `large_tool_results`) never appear. +5. **All** still downloads the whole workspace as `workspace.zip`. +6. Switch conversations → selection clears. +7. By-type view has no checkboxes. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/components/WorkspacePanel.tsx +git commit -m "feat(webui): checkbox multi-select download in workspace tree" +``` + +--- + +## Self-Review Notes (already applied) + +- Spec coverage: client state model ✓ (Task 1), API params/validation/naming ✓ (Task 2), Tree-view UI + toolbar + thread-change reset + URL guard ✓ (Task 3), tests for both layers ✓. "By type view unchanged" and "All kept" are preserved by not touching those paths. +- Type consistency: Task 3 uses exactly the Task 1 exports (`check`/`uncheck` aliased on import to avoid clashing with local names) and the Task 2 query-param contract. +- Deviation from spec, deliberate: excludes are validated with `resolveInside` (lexical) instead of `safeResolve`, because an excluded entry may have been deleted after the user unchecked it — an exclude is only a filter and cannot leak data. Includes still go through `safeResolve`. +- Deviation from spec, deliberate: the empty-selection check is a pre-spawn filesystem walk (`selectionHasFiles`) instead of relying on zip exit 12 — experiments show zip exits 0 with a dirs-only archive when everything is excluded.