Files Sidebar Upload & Folder Creation — 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: Let an operator create folders and upload files/whole-folders (button + drag-and-drop) into a session's working directory from the files sidebar, so the AI model can read them — confined to the session cwd, no hardcoding.
Architecture: A new confined POST /api/v1/fs/upload endpoint (metadata in query params, raw file body streamed to disk) reusing the existing resolveWithinRoot sandbox. Two stateless client helpers in fs.ts (walkDropEntries, uploadFile). FilesPanel gains a header toolbar (New folder / Upload), a bounded-concurrency upload runner, progress, drag-and-drop, and query invalidation to refresh the tree.
Tech Stack: Go 1.25 (chi, net/http), React 19 + TypeScript + TanStack Query v5, Vite, pnpm workspace, sonner toasts, lucide-react icons.
Global Constraints#
- Go module:
github.com/opendray/opendray-v2; new backend code lives in packagefs(internal/fs/handler.go). - Writes MUST be confined to the session cwd via
resolveWithinRoot(target, root)(returns(string, error); 403 on escape). Never trust the clientrelpathverbatim. - Per-file size cap: 250 MiB, streamed via
io.Copyunderhttp.MaxBytesReader. Never buffer whole files. - Conflict policy: auto-rename to
name-1.ext,name-2.ext, … — never overwrite. - Backend JSON via package-local
writeJSON(w, code, body)/writeError(w, code, err)(internal/fs/handler.go:507,513). Mutations return201 Created. - Web client path is
app/shared/src/lib/fs.ts, imported by web as@/lib/fs. Bearer token read synchronously viauseAuth.getState().token(@/stores/auth), exactly asapi()does. - Directory-listing query key is
['fs', <path>]— invalidate it to refresh a folder. - TS:
strictis off butnoUnusedLocals/noUnusedParameters+verbatimModuleSyntaxare on — useimport typefor type-only imports, leave no unused symbols, obeyreact-hooksrules. - Verify web:
pnpm build:web(runstsc -b && vite build) andpnpm lint:web. Verify Go:go test ./internal/fs/.... - Commit after each task. Do NOT push or open a PR until all tasks pass and the operator says so.
Task 1: Backend POST /api/v1/fs/upload endpoint#
Files:
- Modify:
internal/fs/handler.go(addmaxUploadBytesfield toHandlers, register route inMount, add const/types/handler/helpers) - Test:
internal/fs/handler_test.go(new — first test file in this package)
Interfaces:
Consumes: existing
resolveWithinRoot(target, root string) (string, error),writeJSON,writeError.Produces: route
POST /fs/upload; response JSON{ "path": string, "size": int64, "renamed_from": string|omitempty }. Query params:root,dir,relpath; body = raw file bytes.Step 1: Write the failing tests
Create internal/fs/handler_test.go:
- Step 2: Run tests to verify they fail
Run: go test ./internal/fs/... -run TestUpload -v
Expected: FAIL — h.upload undefined, h.maxUploadBytes undefined.
- Step 3: Add the
maxUploadBytesfield and default
In internal/fs/handler.go, change the Handlers struct and NewHandlers (lines 30-39):
- Step 4: Register the route
In Mount (inside the r.Route("/fs", …) block, after the r.Post("/mkdir", h.mkdir) line, ~line 44):
- Step 5: Add the const, types, handler, and helpers
Append to internal/fs/handler.go (e.g. after the mkdir handler, before home):
(io, errors, fmt, os, filepath, strings, net/http are already imported in this file.)
- Step 6: Run tests to verify they pass
Run: go test ./internal/fs/... -run TestUpload -v
Expected: PASS (all five tests).
- Step 7: Vet + full package test
Run: go vet ./internal/fs/... && go test ./internal/fs/...
Expected: no vet complaints; PASS.
- Step 8: Commit
Task 2: Client helpers walkDropEntries + uploadFile#
Files:
- Modify:
app/shared/src/lib/fs.ts
Interfaces:
Consumes:
POST /api/v1/fs/uploadfrom Task 1;useAuthfrom@/stores/auth;APIErrorfrom./api.Produces:
interface WalkedFile { relpath: string; file: File }walkDropEntries(dt: DataTransfer): Promise<WalkedFile[]>interface UploadResult { path: string; size: number; renamed_from?: string }uploadFile(args: { root: string; dir: string; relpath: string; file: File; signal?: AbortSignal; onProgress?: (loaded: number, total: number) => void }): Promise<UploadResult>
Step 1: Add the auth import
At the top of app/shared/src/lib/fs.ts, below the existing import { api, APIError } from './api' (line 1):
- Step 2: Append the walk + upload helpers
Append to app/shared/src/lib/fs.ts:
- Step 3: Typecheck + lint
Run: pnpm build:web && pnpm lint:web
Expected: build succeeds (tsc has no errors), lint clean. If tsc complains that webkitGetAsEntry is missing on DataTransferItem, it is part of lib.dom.d.ts — confirm tsconfig includes "DOM" in lib (it does via Vite's default); no extra typings needed.
- Step 4: Commit
Task 3: "New folder" button in FilesPanel#
Files:
- Modify:
app/web/src/components/sessions/inspector/FilesPanel.tsx
Interfaces:
Consumes: existing
makeDir(parent, name)from@/lib/fs;useQueryClient(TanStack v5);toastfromsonner;Buttonfrom@/components/ui/button.Produces: a header toolbar in
FilesPanelabove the tree (later reused by Task 4's Upload button).Step 1: Extend imports
In FilesPanel.tsx, update the imports:
- Step 2: Add the handler and header to
FilesPanel
Replace the FilesPanel function body (lines 31-57) with:
- Step 3: Typecheck + lint
Run: pnpm build:web && pnpm lint:web
Expected: clean.
- Step 4: Manual verification
Start the web dev server (pnpm dev:web) or use the running gateway UI. Open a session's Files inspector. Click New folder, enter a name. Expected: toast "Created folder
- Step 5: Commit
Task 4: Upload button, bounded-concurrency runner, progress, and panel-level drop#
Files:
- Create:
app/web/src/components/sessions/inspector/uploads.ts(concurrency runner) - Modify:
app/web/src/components/sessions/inspector/FilesPanel.tsx
Interfaces:
Consumes:
uploadFile,walkDropEntries,WalkedFile,UploadResultfrom@/lib/fs.Produces:
interface UploadItem extends WalkedFile { dir: string }runUploads(items, root, opts): Promise<{ results: UploadResult[]; errors: { relpath: string; error: Error }[] }>whereopts = { concurrency?: number; onSettled?: (index: number) => void; signal?: AbortSignal }- Upload button + drop zone on
FilesPanel; drop onto the panel targetscwd.
Step 1: Create the concurrency runner
Create app/web/src/components/sessions/inspector/uploads.ts:
- Step 2: Add upload state, the drive function, hidden inputs, Upload button, and panel drop to
FilesPanel
In FilesPanel.tsx, extend imports with the new helpers and icon:
Then replace the FilesPanel function (from Task 3) with this expanded version:
- Step 3: Typecheck + lint
Run: pnpm build:web && pnpm lint:web
Expected: clean. (Note eslint may flag the as any on the folder input; if @typescript-eslint/no-explicit-any errors, add an inline // eslint-disable-next-line @typescript-eslint/no-explicit-any above that spread line.)
- Step 4: Manual verification
In a session's Files inspector: (a) click Upload, pick 2-3 files → progress bar advances, toast "Uploaded N files", files appear in the tree. (b) Click Upload folder, pick a folder with nested subdirs → the subtree appears under the root. (c) Drag files from the OS file manager onto the panel → same result; the panel shows the accent ring while dragging. (d) Upload a file whose name already exists → toast notes "(1 auto-renamed)" and a -1 file appears. Confirm inside a live session that the model can read an uploaded file (e.g. ask it to cat the path).
- Step 5: Commit
Task 5: Per-folder-row drop targeting#
Files:
- Modify:
app/web/src/components/sessions/inspector/FilesPanel.tsx
Interfaces:
Consumes:
walkDropEntries(already imported); a newonDropFiles(dir, dt)callback threaded fromFilesPanelthroughFsNode.Produces: dropping onto a folder row uploads into that folder instead of the cwd root.
Step 1: Thread an
onDropFilescallback intoFsNode
In FilesPanel, define the callback and pass it to the root FsNode:
Add onDropFiles={onDropFiles} to the root <FsNode … /> (both in the panel-drop container).
- Step 2: Accept and forward the callback in
FsNode
Extend FsNodeProps and the destructure:
Add onDropFiles to the destructured params, pass onDropFiles={onDropFiles} to the recursive <FsNode> for child directories (near line 149).
- Step 3: Make the folder row a drop target
Add local const [rowDrag, setRowDrag] = useState(false) in FsNode. Wrap the folder row's outer div (the group relative flex items-stretch at line 92) with drag handlers and a highlight, stopping propagation so a drop on a child folder doesn't also fire the panel/ancestor:
- Step 4: Typecheck + lint
Run: pnpm build:web && pnpm lint:web
Expected: clean.
- Step 5: Manual verification
Expand the tree, drag a file onto a subfolder row → the file lands inside that subfolder (verify by expanding it), and only that row shows the accent ring during the drag. Dropping on the empty panel background still targets the cwd root (Task 4 behavior unchanged).
- Step 6: Commit
Notes for the implementer#
- Do not modify
POST /sessions/{id}/uploads(the image-attachment endpoint that writes to/tmp) — it serves a different purpose. - Do not add root-confinement to
/fs/mkdir— out of scope; the UI only ever passes an in-cwdparent. - There is no JS test runner; web tasks are verified by
pnpm build:web(typecheck) +pnpm lint:web+ the manual steps. Do not add vitest as part of this plan. - Keep commits per-task. Open a PR only after all five tasks pass and the operator approves.