Image-attachment staging tray (Esc-to-dismiss)#
Date: 2026-07-09
Status: Approved design — ready for implementation plan
Surfaces: web (app/web) + mobile (app/mobile). No backend change.
Problem#
When a user uploads an image to a session, opendray immediately types the returned
server path (/tmp/opendray-uploads/{sessionId}/{hex}.jpg) straight into the PTY —
web via sendInput(res.path) (Terminal.tsx), mobile via _terminal.paste(...)
(session_terminal_view.dart). There is no attachment UI: the path lands directly
in the running CLI's input line. So pressing Esc sends the escape byte to the
CLI, not to opendray, and the user cannot cancel/dismiss the attachment — which is
what they expect, since opendray's other overlays close on Esc.
Decisions (from brainstorming)#
| Decision | Choice |
|---|---|
| Commit model | Chip + explicit insert — upload stages a chip; nothing reaches the PTY until the user clicks Insert. |
| Count | Multiple pending chips (a tray). |
| Chip content | Image icon + filename + ✕. No thumbnail → no new backend endpoint. |
Esc (tray non-empty) |
Clears all staged chips and is swallowed (never reaches the CLI). Empty tray → Esc passes through as today. |
✕ |
Removes that one chip. |
| Insert | Explicit button — types all staged paths into the terminal (bare res.path, space-separated + trailing space), then clears the tray. Content delivered to the CLI is unchanged from today. |
| Backend | None. Reuses /api/v1/sessions/{id}/uploads + sendInput/paste. |
| Strings | All new copy via the shared app/i18n layer — web t(), mobile slang t. (see i18n section). No hardcoded copy. |
| Orphaned upload on cancel | Accepted — the already-uploaded /tmp/opendray-uploads file is left unreferenced, same as today. A delete-on-cancel endpoint is out of scope. |
Architecture & no-hardcoding alignment#
- Shared i18n source.
app/mobile/slang.yamlsetsinput_directory: ../i18n, so both web (react-i18next) and mobile (slang codegen →app/mobile/lib/core/i18n/strings_*.g.dart) read from the sameapp/i18n/{en,es,zh}.json. New strings are added there once. - Styling uses Tailwind design tokens (
bg-card,accent,text-muted-foreground) viacn, never literal colors — matching the surrounding code. - Esc handling uses the capture-phase
windowkeydownpattern already used byWikiLinkSuggestions.tsx. - No new hardcoded paths, formats, or magic values; the inserted text is the existing
res.path. The only literal is the mobile Esc byte'\x1b'(an escape sequence already present in the code).
Unit 1 — Web: AttachmentTray.tsx (new, presentational)#
app/web/src/components/sessions/AttachmentTray.tsx. Pure, no state:
Renders a chip row (icon + truncated name + ✕ per chip) plus an Insert
button. All labels via t(). Styled with cn + tokens, lucide icons
(ImageIcon/Paperclip, X).
Unit 2 — Web: Terminal.tsx wiring#
- Add state
pendingAttachments: AttachmentItem[](useState). - Render
<AttachmentTray items={pendingAttachments} … />directly below the xterm container. - Rewire the three upload paths —
uploadFile(~L115-140), paste (~L356-374), drop (~L398-411): replacesendInput(res.path)withsetPendingAttachments(a => [...a, { path: res.path, name: file.name }]). Remove the current path-echoing toast (the chip is the feedback); keep error toasts. - Insert:
sendInput(pendingAttachments.map(a => a.path).join(' ') + ' '), thensetPendingAttachments([]). Esc: auseEffectregisters a capture-phasewindowkeydownlistener; whene.key === 'Escape'andpendingAttachments.length > 0, calle.preventDefault()+e.stopPropagation()and clear the tray. Cleanup on unmount.
Unit 3 — Mobile: session_terminal_view.dart#
- Add widget state
List<_PendingAttachment>({path, name}). _attachImage: replace_terminal.paste(remotePath)with appending to the list.- Render a chip row above the keyboard bar (✕ per chip + Insert action).
- Keyboard-bar
Esc(currently_send('\x1b'), ~L657-663): becomes conditional — if the tray is non-empty, clear it; else_send('\x1b')as today. - Insert:
_terminal.paste(path)for each (space-separated), then clear. - Strings via slang
t.accessors (see i18n).
i18n keys (add to app/i18n/{en,es,zh}.json)#
Under an appropriate group (e.g. sessions.terminal.attachments for mobile parity;
web reads the same tree via its own namespace). Keys: insert, clear,
removeOne (✕ aria-label, e.g. "Remove {name}"), attached (feedback, e.g.
"Attached {name}"), title/hint if a tray header is shown. English base plus es/zh
translations, placeholders ({name}) identical across locales (i18n-parity CI). After
editing the JSON, run the mobile slang codegen (dart run slang or build_runner)
so strings_*.g.dart regenerates.
Testing#
- Web: no JS test runner (repo condition) →
pnpm --filter web exec tsc -b+ file-scopedeslinton the touched files + a manual pass (upload → chip appears →Escclears /✕removes one / Insert types the path; empty-tray Esc still reaches the CLI). - Mobile:
flutter analyze(after slang codegen) + manual pass mirroring the web checks. - Backend: none.
Out of scope (YAGNI)#
- Delete-on-cancel of the orphaned
/tmp/opendray-uploadsfile (a follow-up endpoint). - Thumbnail previews (would need a new image-serving endpoint).
- A full message composer above the terminal.