Docs / Reference / Image-attachment staging tray Implementation Plan
Documentation

Image-attachment staging tray Implementation Plan

**Goal:** Stage uploaded images as dismissable chips (Esc/✕ to cancel, explicit Insert to commit) instead of typing the path straight into the PTY, on web and mobile.

6 min read Updated Jul 9, 2026 Source Edit History

Image-attachment staging tray 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: Stage uploaded images as dismissable chips (Esc/✕ to cancel, explicit Insert to commit) instead of typing the path straight into the PTY, on web and mobile.

Architecture: No backend change. Web: a presentational AttachmentTray + pending-state in Terminal.tsx; upload stages instead of sendInput, a capture-phase window keydown swallows Esc to clear the tray. Mobile: pending-state in _SessionTerminalViewState, a chip row above the keyboard bar, and the keyboard-bar Esc key clears the tray when non-empty. All strings come from the shared app/i18n layer.

Tech Stack: React 19 + TypeScript + Tailwind (cn) + lucide (web); Flutter + slang i18n (mobile); react-i18next (web i18n). No JS test runner; no Go change.

Global Constraints#

  • No backend change, no new endpoint. Reuse uploadSessionFile / sessionsApiProvider.uploadFile + sendInput (web) / _terminal.paste (mobile). The text delivered to the CLI is unchanged: the bare server path, multiples space-separated with a trailing space.
  • All new copy via app/i18n/{en,es,zh}.json — web under web.sessions.terminal.*, mobile under sessions.terminal.attachments.*. No literal English in components.
  • Placeholders use single braces {name} (both slang and this repo's react-i18next are single-brace); the SAME placeholder tokens must appear in all three locales (i18n-parity CI fails on token mismatch).
  • After editing the JSON, regenerate mobile slang: cd app/mobile && dart run slang (rewrites lib/core/i18n/strings.g.dart + per-locale files).
  • Esc semantics: clears ALL staged chips and is swallowed (never reaches the CLI) — web via capture-phase window listener active only while the tray is non-empty; mobile via the keyboard-bar Esc key. Empty tray → Esc/\x1b behaves exactly as today.
  • Styling via Tailwind tokens + cn (web) and Theme.of(context).colorScheme (mobile) — no hardcoded colors.
  • Verify: web pnpm --filter web exec tsc -b + file-scoped pnpm --filter web exec eslint <paths> (the full pnpm build:web is blocked by a pre-existing root-owned internal/web/dist EACCES); mobile cd app/mobile && flutter analyze. No unit-test runner on either surface — the gate is typecheck/analyze/lint + a manual pass.
  • Commit per task. Open the PR only after all tasks pass and the operator approves; this feature bundles with #433 into v2.11.3.

Task 1: i18n keys (shared) + slang codegen#

Files:

  • Modify: app/i18n/en.json, app/i18n/es.json, app/i18n/zh.json
  • Regenerate: app/mobile/lib/core/i18n/strings*.g.dart (via slang)

Interfaces:

  • Produces (web, under web.sessions.terminal): attachInsert, attachClear, attachRemove (has {name}).

  • Produces (mobile, under sessions.terminal.attachments): insert, clear, remove (has {name}).

  • Step 1: Add the web keys to the web.sessions.terminal object in each locale (place after dropToAttach).

en.json:

[object Promise]

es.json:

[object Promise]

zh.json:

[object Promise]
  • Step 2: Add the mobile keys as a new attachments object inside sessions.terminal in each locale (place after the keyboard object).

en.json:

[object Promise]

es.json:

[object Promise]

zh.json:

[object Promise]
  • Step 3: Validate JSON + placeholder parity

Run:

[object Promise]

Expected: en OK / es OK / zh OK, and the parity script exits 0 (every locale has the same {name} tokens).

  • Step 4: Regenerate slang

Run: cd app/mobile && dart run slang Expected: it reports writing lib/core/i18n/strings.g.dart (+ strings_en.g.dart, strings_es.g.dart, strings_zh.g.dart) with the new attachments keys. Confirm no errors about {{ interpolation.

  • Step 5: Commit
[object Promise]

Task 2: Web — AttachmentTray + Terminal staging#

Files:

  • Create: app/web/src/components/sessions/AttachmentTray.tsx
  • Modify: app/web/src/components/sessions/Terminal.tsx

Interfaces:

  • Consumes: i18n keys web.sessions.terminal.attach{Insert,Clear,Remove} (Task 1); existing sendInput, uploadSessionFile.

  • Produces: interface AttachmentItem { path: string; name: string } (exported from AttachmentTray.tsx); <AttachmentTray items onRemove onInsert onClear />.

  • Step 1: Create AttachmentTray.tsx

[object Promise]
  • Step 2: Add pending state + handlers in Terminal.tsx

Add the import near the other local imports (after import { terminalBufferText } from './terminal-text'):

[object Promise]

Add state next to the existing const [dragActive, setDragActive] = useState(false) (~L96):

[object Promise]
  • Step 3: Stage instead of inserting, in uploadFile

In uploadFile (~L125-131), replace the success branch:

[object Promise]

with:

[object Promise]

(The chip is now the feedback; the loading + error toasts stay.)

  • Step 4: Add insert / remove / clear + Esc handling in Terminal.tsx

Add these callbacks after uploadFile (they can go right before getBufferText):

[object Promise]

Add an Esc effect (place it near the other useEffects, e.g. after the theme-refresh effect ~L437). It only registers while the tray is non-empty, so an empty tray leaves Esc untouched for the CLI:

[object Promise]
  • Step 5: Render the tray

In the return (~L439), inside the root <div ref={rootRef} …>, add the tray right after the {dragActive && …} overlay block (still inside the relative root so it anchors to the bottom):

[object Promise]
  • Step 6: Typecheck + lint

Run:

[object Promise]

Expected: tsc -b 0 errors; eslint clean.

  • Step 7: Manual verification

In a session's terminal: attach an image (button / paste / drag-drop) → a chip appears in a bottom tray, and the path is NOT yet in the terminal. Press Esc → tray clears and nothing is sent to the CLI. Attach again → click → that chip is removed. Attach one or more → click Insert → the path(s) are typed into the terminal and the tray clears. With an empty tray, Esc still reaches the CLI as before.

  • Step 8: Commit
[object Promise]

Task 3: Mobile — staging tray + keyboard-bar Esc#

Files:

  • Modify: app/mobile/lib/features/sessions/session_terminal_view.dart

Interfaces:

  • Consumes: slang keys sessions.terminal.attachments.{insert,clear,remove} (Task 1); existing _terminal.paste, sessionsApiProvider.uploadFile.

  • Produces: _PendingAttachment{path,name}; _AttachmentTray widget; _MobileKeyboardBar gains hasPendingAttachments + onEscClearPending.

  • Step 1: Add the model + state

Near the top of the file's private types (e.g. just above class _ConnectionAccent), add:

[object Promise]

In _SessionTerminalViewState (after late final Terminal _terminal; ~L39), add:

[object Promise]
  • Step 2: Stage instead of pasting, in _attachImage

In _attachImage (~L352-368), replace:

[object Promise]

with:

[object Promise]

(file is promoted to non-null after the earlier if (file == null) return;; capturing attachmentName outside the setState closure avoids Dart's closure-promotion ambiguity, so no ! is needed.)

  • Step 3: Add insert / clear / remove methods

Add to _SessionTerminalViewState (near _attachImage):

[object Promise]
  • Step 4: Add the _AttachmentTray widget

Add near the other private widgets (e.g. above _MobileKeyboardBar's class):

[object Promise]

(t is the slang global — it's already imported and used throughout this file.)

  • Step 5: Render the tray + wire the keyboard bar

In the parent build (~L490-497), insert the tray directly before _MobileKeyboardBar(...) and add the two new props to the bar:

[object Promise]
  • Step 6: Add the props to _MobileKeyboardBar + conditional Esc

On the _MobileKeyboardBar widget class, add the fields + constructor params:

[object Promise]

(and required this.hasPendingAttachments, required this.onEscClearPending, in its constructor).

In its build, change the Esc _Key (~L656-662) to:

[object Promise]
  • Step 7: Analyze

Run: cd app/mobile && flutter analyze Expected: "No issues found!" (or no new issues attributable to these files).

  • Step 8: Manual verification

In a mobile session: attach an image → a chip appears above the keyboard bar; the path is not in the terminal. Tap the keyboard bar's Esc → the tray clears (and no escape is sent). Attach again → tap the chip's → it's removed. Tap Insert → the path is pasted into the terminal and the tray clears. With no chips, Esc sends \x1b as before.

  • Step 9: Commit
[object Promise]

Notes for the implementer#

  • Do not change what the CLI receives — Insert sends the same bare path(s) that sendInput/_terminal.paste sent before, just deferred and space-joined.
  • Do not add a delete-on-cancel endpoint, thumbnails, or a composer — all out of scope.
  • The only literal escape sequence ('\x1b') is pre-existing protocol, not a hardcoding-rule concern.
  • No JS/Go test runner is added; the gate is tsc -b + eslint (web), flutter analyze (mobile), plus the manual passes.
  • After all three tasks pass, this branch is ready to bundle with #433 for the v2.11.3 release (changelog entry added at release time).