Docs / Reference / Files Sidebar Upload & Folder Creation — Implementation Plan
Documentation

Files Sidebar Upload & Folder Creation — Implementation Plan

**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

6 min read Updated Jul 8, 2026 Source Edit History

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 package fs (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 client relpath verbatim.
  • Per-file size cap: 250 MiB, streamed via io.Copy under http.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 return 201 Created.
  • Web client path is app/shared/src/lib/fs.ts, imported by web as @/lib/fs. Bearer token read synchronously via useAuth.getState().token (@/stores/auth), exactly as api() does.
  • Directory-listing query key is ['fs', <path>] — invalidate it to refresh a folder.
  • TS: strict is off but noUnusedLocals/noUnusedParameters + verbatimModuleSyntax are on — use import type for type-only imports, leave no unused symbols, obey react-hooks rules.
  • Verify web: pnpm build:web (runs tsc -b && vite build) and pnpm 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 (add maxUploadBytes field to Handlers, register route in Mount, 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:

[object Promise]
  • 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 maxUploadBytes field and default

In internal/fs/handler.go, change the Handlers struct and NewHandlers (lines 30-39):

[object Promise]
  • Step 4: Register the route

In Mount (inside the r.Route("/fs", …) block, after the r.Post("/mkdir", h.mkdir) line, ~line 44):

[object Promise]
  • Step 5: Add the const, types, handler, and helpers

Append to internal/fs/handler.go (e.g. after the mkdir handler, before home):

[object Promise]

(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
[object Promise]

Task 2: Client helpers walkDropEntries + uploadFile#

Files:

  • Modify: app/shared/src/lib/fs.ts

Interfaces:

  • Consumes: POST /api/v1/fs/upload from Task 1; useAuth from @/stores/auth; APIError from ./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):

[object Promise]
  • Step 2: Append the walk + upload helpers

Append to app/shared/src/lib/fs.ts:

[object Promise]
  • 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
[object Promise]

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); toast from sonner; Button from @/components/ui/button.

  • Produces: a header toolbar in FilesPanel above the tree (later reused by Task 4's Upload button).

  • Step 1: Extend imports

In FilesPanel.tsx, update the imports:

[object Promise]
  • Step 2: Add the handler and header to FilesPanel

Replace the FilesPanel function body (lines 31-57) with:

[object Promise]
  • 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 "; the new folder appears in the tree after it refreshes (expand the root if collapsed).

  • Step 5: Commit
[object Promise]

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, UploadResult from @/lib/fs.

  • Produces:

    • interface UploadItem extends WalkedFile { dir: string }
    • runUploads(items, root, opts): Promise<{ results: UploadResult[]; errors: { relpath: string; error: Error }[] }> where opts = { concurrency?: number; onSettled?: (index: number) => void; signal?: AbortSignal }
    • Upload button + drop zone on FilesPanel; drop onto the panel targets cwd.
  • Step 1: Create the concurrency runner

Create app/web/src/components/sessions/inspector/uploads.ts:

[object Promise]
  • 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:

[object Promise][object Promise][object Promise][object Promise]

Then replace the FilesPanel function (from Task 3) with this expanded version:

[object Promise]
  • 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
[object Promise]

Task 5: Per-folder-row drop targeting#

Files:

  • Modify: app/web/src/components/sessions/inspector/FilesPanel.tsx

Interfaces:

  • Consumes: walkDropEntries (already imported); a new onDropFiles(dir, dt) callback threaded from FilesPanel through FsNode.

  • Produces: dropping onto a folder row uploads into that folder instead of the cwd root.

  • Step 1: Thread an onDropFiles callback into FsNode

In FilesPanel, define the callback and pass it to the root FsNode:

[object Promise]

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:

[object Promise]

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:

[object Promise]
  • 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
[object Promise]

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-cwd parent.
  • 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.