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 underweb.sessions.terminal.*, mobile undersessions.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(rewriteslib/core/i18n/strings.g.dart+ per-locale files). Escsemantics: clears ALL staged chips and is swallowed (never reaches the CLI) — web via capture-phasewindowlistener active only while the tray is non-empty; mobile via the keyboard-barEsckey. Empty tray →Esc/\x1bbehaves exactly as today.- Styling via Tailwind tokens +
cn(web) andTheme.of(context).colorScheme(mobile) — no hardcoded colors. - Verify: web
pnpm --filter web exec tsc -b+ file-scopedpnpm --filter web exec eslint <paths>(the fullpnpm build:webis blocked by a pre-existing root-ownedinternal/web/distEACCES); mobilecd 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.terminalobject in each locale (place afterdropToAttach).
en.json:
es.json:
zh.json:
- Step 2: Add the mobile keys as a new
attachmentsobject insidesessions.terminalin each locale (place after thekeyboardobject).
en.json:
es.json:
zh.json:
- 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
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); existingsendInput,uploadSessionFile.Produces:
interface AttachmentItem { path: string; name: string }(exported fromAttachmentTray.tsx);<AttachmentTray items onRemove onInsert onClear />.Step 1: Create
AttachmentTray.tsx
- Step 2: Add pending state + handlers in
Terminal.tsx
Add the import near the other local imports (after import { terminalBufferText } from './terminal-text'):
Add state next to the existing const [dragActive, setDragActive] = useState(false) (~L96):
- Step 3: Stage instead of inserting, in
uploadFile
In uploadFile (~L125-131), replace the success branch:
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):
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:
- 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):
- 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
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};_AttachmentTraywidget;_MobileKeyboardBargainshasPendingAttachments+onEscClearPending.Step 1: Add the model + state
Near the top of the file's private types (e.g. just above class _ConnectionAccent), add:
In _SessionTerminalViewState (after late final Terminal _terminal; ~L39), add:
- Step 2: Stage instead of pasting, in
_attachImage
In _attachImage (~L352-368), replace:
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):
- Step 4: Add the
_AttachmentTraywidget
Add near the other private widgets (e.g. above _MobileKeyboardBar's class):
(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:
- Step 6: Add the props to
_MobileKeyboardBar+ conditional Esc
On the _MobileKeyboardBar widget class, add the fields + constructor params:
(and required this.hasPendingAttachments, required this.onEscClearPending, in its constructor).
In its build, change the Esc _Key (~L656-662) to:
- 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
Notes for the implementer#
- Do not change what the CLI receives — Insert sends the same bare path(s) that
sendInput/_terminal.pastesent 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).