diff --git a/AGENTS.md b/AGENTS.md
index f5b65a40..25845f18 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -295,7 +295,7 @@ invincible/
| Logical agent cwd + workspace-root↔abs canonicalization (`change_dir` / session / default env; `canonicalizePath(R, p)` / `workspaceAbsToRel(R, abs)` / `resolvePathForTool(R, cwd, p)` / `rewriteExecRootToRel(R, text)` in `lib/agent/workPath.ts`) | `lib/agent/workPath.ts`, `lib/agent/tools.ts`, `lib/agent/runAgent.ts`, `lib/agent/agentBody.ts`, `lib/sandbox/config.ts`, `lib/sessionStore.ts`, `lib/harnessChat.ts`, `lib/agentApi.ts`, `lib/sessionCloudCaps.ts` (shared client-safe `sanitizeSessionCwd` + Redis-safe opaque id predicate), [docs/sandbox.md](docs/sandbox.md), [docs/session-model.md](docs/session-model.md), [docs/agent-stream.md](docs/agent-stream.md). Tool paths accept **in-jail absolute paths** on all FS tools + `change_dir` + `exec` cwd: an absolute under the per-binding jail root R (`resolved.value.workspaceRoot` → `RunAgentParams.workspaceRoot` → `createAgentTools`) is canonicalized to the same workspace-relative freshness key as its relative form (BYO + Vercel parity); out-of-jail absolutes and `..`/symlink escapes fail closed. Absolute paths under `R` that **appear in `exec` stdout/stderr** are likewise rewritten to workspace-relative (`rewriteExecRootToRel` in `lib/agent/workPath.ts`, applied to `result.stdout`/`result.stderr` separately) so `exec pwd` ≡ `pwd`/annotations; when `R` is unresolvable the exec output passes through byte-for-byte (fail-open), and rewrites are capped and never throw. When R is unresolvable (BYO daemon down/pre-v2 — `workspaceRoot === null`) absolute is rejected (“root unavailable — use workspace-relative”) while relative + cwd still work. Initial request/session `cwd` stays relative-only; `.` is the workspace-root default session start (there is no `SANDBOX_DEFAULT_CWD` env knob), `..` walks up toward the workspace root and errors only past it, and an **exact ancestor** of cwd re-roots cleanly (`change_dir invincible` from `cwd=invincible/docs` → `invincible`, not the phantom `invincible/docs/invincible`) while a name-prefix sibling is never re-rooted. P1/GAP-1 (#452/#330): `cwd` + `activeSandboxId` are **session-owned** and ride the Redis record (`meta.{logicalCwd,activeSandboxId}`). `activeSandboxId` is now **server-resolved** (routing override via `lib/tenancy/resolveSandbox.ts` `requestedSandboxId`), not carry-only. A **confirmed successful `change_dir`** is persisted as the session cwd even when the turn later cancels / times out / hard-errors (`lib/harnessChat.ts` host-side `liveCwd`); the success path still prefers the authoritative `agentResult.cwd`, and only a confirmed `change_dir` (never an errored one) is stored on a failed/aborted turn |
| Cloud multi-device harness session (Redis multi-session, `/api/sessions*`, hybrid local+cloud; **phase 0 #515 envelope + Blob transcript carrier**) | `app/api/sessions/*` (+ `app/api/sessions/[id]/envelope/*`, `[id]/transcript/*`), `lib/sessionRepository.ts`, `lib/sessionCloudCaps.ts`, `lib/sessions/*` (+ `lib/sessions/blobStore.ts`, `blobStores.ts`), `lib/tenancy/harnessSessionsRedis.ts`, `lib/tenancy/harnessSessions.ts` (archive read + shared validator), `lib/di/index.ts` (root), `app/harness/HarnessHost.tsx`, `middleware.ts`, [docs/session-model.md](docs/session-model.md), [docs/bring-your-own.md](docs/bring-your-own.md), [SECURITY.md](SECURITY.md) — one-shot Postgres→Redis backfill: GHA **`sessions-redis-backfill`** (idempotent per-user marker); Postgres `harness_sessions` is a read-only archive. P1/GAP-1 (#452): session-carrier `meta.{logicalCwd,activeSandboxId}` folds into the PUT body and restores on pull/adopt; **plan #616 (source #610)** adds the reserved `meta.selectedModel` session carrier for the selected model pick (restore by id after the model catalog push; server **drops a poisoned value to unset**, never a 400). **Phase 0 (#515):** the transcript lives in **Vercel Blob** (`BLOB_READ_WRITE_TOKEN` / BYO S3-R2 seam) pointed to by `meta.transcriptPointer` on the small Redis envelope (`harness:envelope:*`); server mints short-lived scoped upload URLs for **client→Blob** uploads; legacy full-record GET stays for roll-forward while old blobs stay small. Envelope upsert/read: `PUT`/`GET /api/sessions/:id/envelope`; mint/read: `POST`/`GET /api/sessions/:id/transcript` |
-| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v17** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch) |
+| Harness stream chrome (Thinking collapse/caps, live tools) | `lib/harnessChat.ts`, `native/harness/src/ui.zig` (facade + `frame`; transcript band owner), `native/harness/src/ui/thinking.zig` (Thinking kind), `native/harness/src/transcript_split.zig` (collapsible left rail + session list in the transcript band), `native/harness/src/session_catalog.zig` (v17 catalog + pending switch), protocol **v18** in `lib/harnessBridge.ts` (Stop cancel v9; Thinking kind v8; tool-run kind 6 v10→live paint v11; skill-attach kind 7 v12; **status-slot store v13**; **turn-clock feed v14**; **v14 addendum** `inv_set_busy_tick`; **v16** model persist; **v17** session-rail catalog + pending switch; **v18** `inv_queued_count` submit-queue depth) |
| Workspace status bar (protocol v13 status-slot store; bridge overall **v14** — plan #538/#541 + Phase 2 git #540 + Phase 3 context/usage #539, **two-line bottom status bar under the composer — #554/#555/#570**) | `native/harness/src/{bridge,ui,model_picker,model_catalog}.zig` (status-slot store + two-line 64 px bar directly **below the composer**: **line 1** = identity (spinner · `h:{build-id}` · model menu) relocated from the deleted header band, **line 2** = `paintStatusSlots` right-aligned slot pack — header merged by plan #570; each line has explicit 32 px height so the model picker (`PICKER_TRIGGER_H`=32) fits and slots never clip; sandbox/cwd/git + context/usage slots — context painted generically via `STATUS_SLOT_DROP_ORDER`), `lib/harnessBridge.ts` (`StatusSlot`, `setStatusSlot`/`getStatusSlot`/`clearStatusSlot`/`clearStatusSlots`, `STATUS_SLOT_MAX_BYTES` mirror), `lib/harnessChat.ts` (`foldStatusSlots` — folds `activeSandboxId` + `cwd` + **context/usage** (`formatUsageSummary`, re-sanitized on read) after hydrate, after **every** agent turn — success **and** fail (403-clear / committed `change_dir` repaint the pack — PR #543), and **live mid-turn on tool results** (Phase 2 #627 / #625: a confirmed `change_dir` or successful `meta_sandbox_switch` repaints sandbox/cwd immediately, plus the host persists via `onSessionPatch`); context default **hidden** on missing usage, abort/cancel carries the prior honest value forward; host-ellipsized to the byte cap before the wire; `refreshGitStatusSlot` — host polls the read-only `GET /api/harness/status` probe on a ~10 s cadence **and** on-demand after a successful `exec` or `meta_sandbox_switch` mid-turn (not only the cadence), fail-soft keeps the last git value on transient error/429), `app/harness/HarnessHost.tsx` (Clear/New clears the pack; wires the git cadence + `onSessionPatch` → persist), `app/api/harness/status/route.ts` (read-only git probe: envelope-authoritative bind (`meta.activeSandboxId` wins over Redis-safe `?sandboxId=` carry), `resolveSandbox` → bounded argv-only read-only git at the bind workspace root via `lib/agent/statusProbe.ts`, per-instance rate cap `STATUS_PROBE_MIN_INTERVAL_MS`; middleware matcher + in-route `requireSessionUser` dual gate; never mutates a session/envelope — no Production write), `lib/agent/statusProbe.ts` (`STATUS_GIT_PROBE_OUT_MAX_BYTES`=512, fail-soft `{}`), `lib/sessionCloudCaps.ts` (`STATUS_SLOT_MAX_BYTES` = 96 + `STATUS_PROBE_MIN_INTERVAL_MS` = 2000 — client-safe single sources), **context/usage carrier:** `lib/agent/usageSummary.ts` (bounded provider-usage mapper `mapProviderUsage` / read-side `sanitizeUsageSummary` / host `formatUsageSummary`, `USAGE_SUMMARY_MAX_BYTES` = 96 — NEW cap), emitted **live mid-stream** from `finish` parts (aggregate only — never `finish-step` per-step counts) in `lib/agent/agentStream.ts` (SSE `usage` event), reconciled at the final `done.usage` / JSON result / chat result in `lib/agent/runAgent.ts` (+ `app/api/chat/route.ts`), parsed by `lib/agentApi.ts` / `lib/chatApi.ts`, mirrored on `SessionSnapshot.usage` (`lib/sessionStore.ts`; reserved cloud `meta.usage` JSON string, drop-to-unset on poison), docs: [docs/feature-divide.md](docs/feature-divide.md), [docs/harness-limits.md](docs/harness-limits.md), [docs/agent-stream.md](docs/agent-stream.md), [docs/session-model.md](docs/session-model.md) | |
| Tool-run aggregation + expandable transcript control (#325) | `lib/agent/agentStream.ts` (backend `tool_result.preview` — bounded/redacted L2 detail), `lib/toolRun.ts` (encode/decode, host aggregation, `meaningfulDetail` preview→`detail`, `mergeToolRunPayloads`/`encodeToolRunPayload` hydrate coalesce), `lib/harnessChat.ts` (stream/JSON aggregation → kind 6 `tool_run`, **live-painted**: a tool event opens/grows ONE card immediately via `update_last` — grouping keys off the host's `lastRingRowIsToolRun` flag, the only ring writer: grow iff the last ring row is a tool-run, else a NEW card at `1`; a thinking/assistant/user/error row last is a separator; commit-once is removed; reload coalescing of consecutive `tool_run` rows via `coalesceToolRunMessages` in `pushSessionToBridge`), `lib/sessionStore.ts` role `tool_run`, `native/harness/src/rich/toolrun.zig` (decode), `native/harness/src/ui/toolrun.zig` (`paintToolRun` — **headerless**: no `tools` kind band; 📋 copy on the header row; status glyphs as the single channel from embedded faces, `✓`/`✗` DejaVu symbols + `…` Noto; L2 preview in Vera Sans Mono for command/output tools **or any multi-line detail**, body otherwise; short single-line results → static label, no blank expander), `native/harness/src/bridge.zig` + `lib/harnessBridge.ts` (protocol **v11**; additive test-only ring readback `inv_message_*_at`), protocol **v11**; expand state + stick-to-bottom reuse dvui `reorder_tree.zig` / `scrolling.zig` idioms |
diff --git a/docs/feature-divide.md b/docs/feature-divide.md
index 5c939210..5b2aa267 100644
--- a/docs/feature-divide.md
+++ b/docs/feature-divide.md
@@ -128,7 +128,7 @@ re-resolved each turn.
| Concern | Path |
|---------|------|
| Host shell | `app/harness/HarnessHost.tsx` |
-| Bridge TS (protocol **v17**) | `lib/harnessBridge.ts` |
+| Bridge TS (protocol **v18**) | `lib/harnessBridge.ts` |
| Image fetch/decode | `lib/harnessImages.ts` |
| Model catalog API | `app/api/models/route.ts` |
| Admin inference keys | `app/admin/inference/*` |
@@ -141,7 +141,7 @@ re-resolved each turn.
| Theme | `native/harness/src/palette.zig` ↔ `lib/palette.ts` |
| Export whitelist | `native/harness/build.zig` |
-Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **17** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; **17** adds the session-rail catalog + pending switch).
+Host `HARNESS_PROTOCOL_VERSION` must equal Wasm `PROTOCOL_VERSION` (currently **18** — 13 added the additive status-slot store; 14 the scalar turn-clock feed `inv_set_turn_elapsed`; 15 added the busy-tick `inv_set_busy_tick`; 16 added model-selection persistence `inv_set_selected_model` + pending-model-change; 17 added the session-rail catalog + pending switch; **18** adds `inv_queued_count` for the in-canvas submit queue).
Mismatch → load error; rebuild both sides. Image **bytes** enter only via bridge put; never dual DOM `
` product surface.
## Related
diff --git a/docs/harness-limits.md b/docs/harness-limits.md
index 0a0d12fd..03e44ca4 100644
--- a/docs/harness-limits.md
+++ b/docs/harness-limits.md
@@ -46,7 +46,7 @@ click/tap scrolls the transcript so the message is back in view near the top.
| Chord | Action |
|-------|--------|
| **Enter** (composer focused) | Insert a newline (composer is multi-line) |
-| **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt |
+| **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt when idle; **enqueue** a follow-up when Busy |
| Tab | DOM nav / Clear (canvas uses pointer + dvui focus) |
| Composer focus | Requested on ready and after each send |
@@ -65,15 +65,15 @@ Vertical bands inside the Wasm root (not a DOM panel):
| Band | Behavior |
|------|----------|
| **Transcript** | Horizontal pair inside the leftover band from the canvas top to the composer: a collapsible **left rail** (closed = 40 px icon strip; open = 220 px TEAL column with the session list, scroll inside the rail) plus the `scrollArea`. Row labels are pixel-ellipsized to the 220 px column (UTF-8, trailing `…`). The auto vertical bar sits on the **canvas right edge**. No pane pad, no card fill/border on the scroller. Rail height always equals the transcript band (`scroll_h`) — it shrinks when the composer grows up. |
-| **Composer chrome** | Single-row text field + one trailing **icon-only** button (▶ Send / ■ Stop); dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes padding into min/max — sizes are passed minus 2×pad so the 44/124 chrome caps hold). Send/Stop icon is bottom-pinned (`gravity_y = 1.0`) so it stays on the field baseline at all heights (plan #579, revises #457 fixed band) |
+| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes padding into min/max — sizes are passed minus 2×pad so the 44/124 chrome caps hold). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar |
| **Status bar** | **Two-line** always-mounted full-width strip **below the composer**: line 1 = identity (spinner · `h:{build-id}` · model menu), line 2 = status-slot pack (sandbox · cwd · git · context); fixed `STATUS_BAR_H` = 64 px, never collapses (plan #570, merged from header per plan #555 → #554) |
| Rule | Behavior |
|------|----------|
| Composer visibility | Fully on-canvas while the harness is ready; not optional |
-| Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the composer (height = viewport − dynamic composer `composer_h` − status bar `STATUS_BAR_H`; no inter-band gap). The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas |
+| Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas |
| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — `min_size_content` / `max_size_content` are passed minus 2×pad because `TextEntryWidget.init` bakes padding in. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. The Send/Stop icon at `gravity_y = 1.0` stays bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) |
-| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so it stays glued to the status bar while the field grows up (plan #575 lock). Idle = ▶ Send (submit when non-empty); Busy = ■ Stop (protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) |
+| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. Idle = one ▶ Send (submit when non-empty). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) |
| Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) |
| Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — per-scalar color from `warm_accent` (head) through `warm_muted`/`warm_border` (tail, capped — `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells), ~5.4 s full cycle at 10 Hz. The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge maps busy ticks to 1..255 (wrapping 255→1, never 0) so a long turn (≥ 25.6 s) never flashes solid mid-animation. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load |
| Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases |
@@ -81,6 +81,21 @@ Vertical bands inside the Wasm root (not a DOM panel):
| Solid chrome | Composer band uses TEAL fill so transcript paint cannot show through |
| Forbidden | Nesting the composer inside the transcript `scrollArea`; dual DOM chat input |
+### Submit queue
+
+While a turn is **Busy**, follow-up prompts go into an in-canvas FIFO **above** the composer instead of starting a parallel turn.
+
+| Topic | Behavior |
+|-------|----------|
+| Enqueue | Busy Ctrl/Cmd+Enter or the extra ▶ icon appends the composer text. Idle ▶ still starts a turn immediately |
+| Visibility | Compact TEAL list (header + up to 3 rows, extra rows scroll). Each row: first-line preview (100 bytes, UTF-8 safe, slash commands kept) + edit + remove |
+| Edit / cancel | Edit in place (Ctrl+Enter / blur saves; Escape reverts). × removes one item. **Clear** empties the queue. The in-flight turn is untouched |
+| When it runs | After the current turn reaches Ready or error, the head submits as a normal user turn (same `pending_submit` path). Promote is held while a row is being edited |
+| Stop | Cancels **only** the in-flight turn. The queue stays and drains after Ready |
+| Lifetime | Wasm-ephemeral. Refresh, New session, Clear, and session switch wipe it. Not stored on the cloud session |
+| Full (16) | Further enqueues are rejected; the composer text stays so nothing is silently dropped |
+| Auto-continue | A later host auto-continue (session TODO drain) must not start while the queue is non-empty or a pending submit is unacked |
+
## Workspace status bar (bottom status bar under the composer, protocol v13)
| Topic | Behavior |
diff --git a/lib/harnessBridge.test.ts b/lib/harnessBridge.test.ts
index 90dcad4b..ed448e70 100644
--- a/lib/harnessBridge.test.ts
+++ b/lib/harnessBridge.test.ts
@@ -132,6 +132,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid
inv_ack_pending_submit: () => {
pending = null;
},
+ inv_queued_count: () => 0,
inv_set_can_load_earlier: (v: number) => {
canLoad = v ? 1 : 0;
if (!canLoad) loadEarlier = false;
@@ -807,7 +808,7 @@ describe('skill_attached kind (protocol v12)', () => {
// Distinct from the protocol version (13) — a hardcoded kind 13 would be an
// unknown kind to the Wasm painter.
expect(MessageKind.SkillAttached).not.toBe(HARNESS_PROTOCOL_VERSION);
- expect(HARNESS_PROTOCOL_VERSION).toBe(17);
+ expect(HARNESS_PROTOCOL_VERSION).toBe(18);
});
it('push/readback round-trips a skill_attached row', () => {
@@ -836,8 +837,8 @@ describe('setTurnElapsed (protocol v14)', () => {
expect(exp.__turnElapsed()).toBe(0);
});
- it('version bumped to 17 and the export is REQUIRED (fail-closed when missing)', () => {
- expect(HARNESS_PROTOCOL_VERSION).toBe(17);
+ it('version bumped to 18 and the export is REQUIRED (fail-closed when missing)', () => {
+ expect(HARNESS_PROTOCOL_VERSION).toBe(18);
const exp = makeMockExports() as unknown as WebAssembly.Exports;
expect(isHarnessBridgeExports(exp)).toBe(true);
// A rebuilt Wasm that omits inv_set_turn_elapsed fails bridge-load closed,
@@ -903,3 +904,15 @@ describe('status-slot pack (protocol v13)', () => {
expect(bridge.getStatusSlot(0)).toBe('');
});
});
+
+describe('queuedCount (protocol v18)', () => {
+ it('reads inv_queued_count and fails closed when the export is missing', () => {
+ expect(HARNESS_PROTOCOL_VERSION).toBe(18);
+ const exp = makeMockExports();
+ const bridge = new HarnessBridge(exp);
+ expect(bridge.queuedCount()).toBe(0);
+ const record = exp as unknown as Record;
+ delete record.inv_queued_count;
+ expect(isHarnessBridgeExports(record as unknown as WebAssembly.Exports)).toBe(false);
+ });
+});
diff --git a/lib/harnessBridge.ts b/lib/harnessBridge.ts
index b9dbc654..42522ff8 100644
--- a/lib/harnessBridge.ts
+++ b/lib/harnessBridge.ts
@@ -30,7 +30,8 @@ import {
// v17: session-rail catalog + pending switch — `inv_clear_session_catalog`,
// `inv_push_session_catalog_entry`, `inv_set_current_session`,
// `inv_has_pending_session_switch` / len / copy / ack. Additive, now REQUIRED.
-export const HARNESS_PROTOCOL_VERSION = 17 as const;
+// v18: submit-queue count — `inv_queued_count` (Wasm-ephemeral FIFO). Additive, now REQUIRED.
+export const HARNESS_PROTOCOL_VERSION = 18 as const;
/** XOR constant used by `inv_ping` on the Wasm side. */
export const INV_PING_XOR = 0xa5a5 as const;
@@ -144,6 +145,7 @@ export type HarnessBridgeExports = {
inv_pending_submit_len: () => number;
inv_pending_submit_copy: (outPtr: number, maxLen: number) => number;
inv_ack_pending_submit: () => void;
+ inv_queued_count: () => number;
inv_set_can_load_earlier: (v: number) => void;
inv_has_pending_load_earlier: () => number;
inv_ack_pending_load_earlier: () => void;
@@ -226,6 +228,7 @@ const REQUIRED_FNS: Exclude[] = [
'inv_pending_submit_len',
'inv_pending_submit_copy',
'inv_ack_pending_submit',
+ 'inv_queued_count',
'inv_set_can_load_earlier',
'inv_has_pending_load_earlier',
'inv_ack_pending_load_earlier',
@@ -558,6 +561,11 @@ export class HarnessBridge {
return this.exports.inv_has_pending_submit() !== 0;
}
+ /** Protocol v18 — Wasm-ephemeral follow-up queue depth. */
+ queuedCount(): number {
+ return this.exports.inv_queued_count();
+ }
+
/** Read + ack pending Wasm→JS submit, or null if none. */
takePendingSubmit(): string | null {
if (!this.hasPendingSubmit()) return null;
diff --git a/lib/harnessCanAutoContinue.test.ts b/lib/harnessCanAutoContinue.test.ts
new file mode 100644
index 00000000..bb360185
--- /dev/null
+++ b/lib/harnessCanAutoContinue.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from 'vitest';
+import { canAutoContinue } from './harnessCanAutoContinue';
+
+describe('canAutoContinue', () => {
+ const rows: Array<{
+ name: string;
+ inflight: boolean;
+ queuedCount: number;
+ hasPendingSubmit: boolean;
+ want: boolean;
+ }> = [
+ { name: 'idle empty', inflight: false, queuedCount: 0, hasPendingSubmit: false, want: true },
+ { name: 'inflight', inflight: true, queuedCount: 0, hasPendingSubmit: false, want: false },
+ { name: 'queued', inflight: false, queuedCount: 1, hasPendingSubmit: false, want: false },
+ { name: 'pending submit', inflight: false, queuedCount: 0, hasPendingSubmit: true, want: false },
+ { name: 'queued + pending', inflight: false, queuedCount: 2, hasPendingSubmit: true, want: false },
+ { name: 'inflight + queued', inflight: true, queuedCount: 3, hasPendingSubmit: false, want: false },
+ { name: 'inflight + pending', inflight: true, queuedCount: 0, hasPendingSubmit: true, want: false },
+ { name: 'all blocked', inflight: true, queuedCount: 4, hasPendingSubmit: true, want: false },
+ ];
+
+ for (const row of rows) {
+ it(row.name, () => {
+ expect(
+ canAutoContinue({
+ inflight: row.inflight,
+ queuedCount: row.queuedCount,
+ hasPendingSubmit: row.hasPendingSubmit,
+ }),
+ ).toBe(row.want);
+ });
+ }
+});
diff --git a/lib/harnessCanAutoContinue.ts b/lib/harnessCanAutoContinue.ts
new file mode 100644
index 00000000..fd070e2d
--- /dev/null
+++ b/lib/harnessCanAutoContinue.ts
@@ -0,0 +1,13 @@
+/**
+ * Gate for a future host auto-continue (session TODO drain).
+ *
+ * Operator queued follow-ups and an unacked pending submit always win.
+ * There is no HarnessHost caller yet — do not invent one.
+ */
+export function canAutoContinue(opts: {
+ inflight: boolean;
+ queuedCount: number;
+ hasPendingSubmit: boolean;
+}): boolean {
+ return !opts.inflight && opts.queuedCount === 0 && !opts.hasPendingSubmit;
+}
diff --git a/lib/harnessChat.test.ts b/lib/harnessChat.test.ts
index 9f72d38b..80ecc653 100644
--- a/lib/harnessChat.test.ts
+++ b/lib/harnessChat.test.ts
@@ -127,6 +127,7 @@ function makeMockExports(): HarnessBridgeExports & {
inv_pending_submit_len: () => 0,
inv_pending_submit_copy: () => 0,
inv_ack_pending_submit: () => {},
+ inv_queued_count: () => 0,
inv_set_can_load_earlier: (v: number) => {
canLoadEarlier = v ? 1 : 0;
},
diff --git a/lib/harnessHostModelPersist.test.ts b/lib/harnessHostModelPersist.test.ts
index cec78598..ba8491c7 100644
--- a/lib/harnessHostModelPersist.test.ts
+++ b/lib/harnessHostModelPersist.test.ts
@@ -109,6 +109,7 @@ function makeMockExports(overrides?: Partial): HarnessBrid
inv_pending_submit_len: () => 0,
inv_pending_submit_copy: () => 0,
inv_ack_pending_submit: () => {},
+ inv_queued_count: () => 0,
inv_set_can_load_earlier: () => {},
inv_has_pending_load_earlier: () => 0,
inv_ack_pending_load_earlier: () => {},
diff --git a/native/harness/README.md b/native/harness/README.md
index 93401209..27de2bbd 100644
--- a/native/harness/README.md
+++ b/native/harness/README.md
@@ -110,6 +110,7 @@ Host is dvui’s `web.js`. Required exports (app + backend):
| `inv_set_selected_model` / `inv_has_pending_model_change` / `inv_ack_pending_model_change` | Protocol v16 model-selection persistence |
| `inv_clear_session_catalog` / `inv_push_session_catalog_entry` / `inv_session_catalog_count` / `inv_set_current_session` | Protocol v17 session-rail catalog (host → Wasm) |
| `inv_has_pending_session_switch` / `inv_pending_session_switch_len` / `_copy` / `inv_ack_pending_session_switch` | Protocol v17 pending session switch (Wasm → host) |
+| `inv_queued_count` | Protocol v18 — Wasm-ephemeral follow-up queue depth (host / auto-continue seam) |
| `inv_set_turn_elapsed` | **v14** whole-turn busy clock — the host pushes elapsed wall-clock seconds while a turn runs; the Wasm busy row formats/appends `Waiting for model… · mm:ss` in-canvas |
Whitelist: `build.zig` → `export_symbol_names` (Zig 0.16 freestanding + `entry = .disabled` strips unrooted exports).
@@ -121,7 +122,7 @@ Inference stays on the host: `POST /api/chat` and `POST /api/agent` hold
| | |
|--|--|
-| **Protocol version** | `17` (v16 added model-selection persistence; **v17** adds the session-rail catalog + pending switch) |
+| **Protocol version** | `18` (v17 added the session-rail catalog + pending switch; **v18** adds `inv_queued_count` for the in-canvas submit queue) |
| **TS** | `lib/harnessBridge.ts` |
| **Zig** | `src/bridge.zig` |
| **Host** | `app/harness/HarnessHost.tsx` (shell: load + bridge + APIs) |
diff --git a/native/harness/build.zig b/native/harness/build.zig
index 89b747f5..69f41d22 100644
--- a/native/harness/build.zig
+++ b/native/harness/build.zig
@@ -87,6 +87,7 @@ pub fn build(b: *std.Build) void {
"inv_pending_submit_len",
"inv_pending_submit_copy",
"inv_ack_pending_submit",
+ "inv_queued_count",
"inv_set_can_load_earlier",
"inv_has_pending_load_earlier",
"inv_ack_pending_load_earlier",
@@ -170,7 +171,7 @@ pub fn build(b: *std.Build) void {
test_parse.dependOn(&run_parse_tests.step);
// Host unit tests for cache / link allowlist / kind gate (no dvui frame).
- const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, link_click, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + text_wave (#655) + rect_spinner (#651) + busy_spinner + elapsed_clock + model_catalog + session_catalog");
+ const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, link_click, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + text_wave (#655) + rect_spinner (#651) + busy_spinner + elapsed_clock + model_catalog + session_catalog + submit_queue + queue_preview");
test_rich.dependOn(&run_parse_tests.step);
const cache_tests = b.addTest(.{
@@ -227,6 +228,30 @@ pub fn build(b: *std.Build) void {
test_rich.dependOn(&b.addRunArtifact(chip_preview_tests).step);
}
+ // Host unit tests for submit_queue.zig + queue_preview.zig (plan #664).
+ {
+ const submit_queue_tests = b.addTest(.{
+ .name = "submit_queue",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/submit_queue.test.zig"),
+ .target = host_target,
+ .optimize = optimize,
+ }),
+ });
+ test_rich.dependOn(&b.addRunArtifact(submit_queue_tests).step);
+ }
+ {
+ const queue_preview_tests = b.addTest(.{
+ .name = "queue_preview",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/queue_preview.test.zig"),
+ .target = host_target,
+ .optimize = optimize,
+ }),
+ });
+ test_rich.dependOn(&b.addRunArtifact(queue_preview_tests).step);
+ }
+
// Host unit tests for elapsed_clock.zig (plan #567, protocol v14 turn-clock
// feed): the four `m:ss` / `h:mm:ss` format cases + undersized-buffer guard.
// Pure, no dvui.
diff --git a/native/harness/src/bridge.zig b/native/harness/src/bridge.zig
index 7145e339..84f41e0e 100644
--- a/native/harness/src/bridge.zig
+++ b/native/harness/src/bridge.zig
@@ -13,6 +13,7 @@ const composer_text = @import("composer_text.zig");
const ring_slot = @import("ring_slot.zig");
const model_catalog = @import("model_catalog.zig");
const session_catalog = @import("session_catalog.zig");
+const submit_queue = @import("submit_queue.zig");
/// Bump on breaking export/layout changes. Must match `HARNESS_PROTOCOL_VERSION` in TS.
/// v9: pending cancel (user Stop) — additive exports.
@@ -31,7 +32,9 @@ const session_catalog = @import("session_catalog.zig");
/// v17: session-rail catalog + pending switch — `inv_clear_session_catalog`,
/// `inv_push_session_catalog_entry`, `inv_set_current_session`,
/// `inv_has_pending_session_switch` / len / copy / ack. Additive, now REQUIRED.
-pub const PROTOCOL_VERSION: u32 = 17;
+/// v18: submit-queue count — `inv_queued_count` (Wasm-ephemeral FIFO; host
+/// auto-continue must wait for 0). Additive, now REQUIRED.
+pub const PROTOCOL_VERSION: u32 = 18;
pub const Lifecycle = enum(u8) {
boot = 0,
@@ -121,6 +124,8 @@ var echo_len: u32 = 0;
var pending_submit: [SUBMIT_CAP]u8 = undefined;
var pending_submit_len: u32 = 0;
var has_pending_submit: bool = false;
+/// Wasm-ephemeral operator follow-up FIFO (protocol v18).
+var queue: submit_queue.Q = .{};
/// Host sets when SessionStore has messages older than the current ring window.
var can_load_earlier: bool = false;
var has_pending_load_earlier: bool = false;
@@ -231,6 +236,53 @@ pub fn queueSubmitFromUi(text: []const u8) void {
refresh();
}
+pub fn enqueueFromUi(text: []const u8) error{ Blank, Full }!void {
+ try submit_queue.push(&queue, text);
+ refresh();
+}
+
+/// Promote the queue head into `queueSubmitFromUi` when the turn is terminal
+/// and the operator is not mid-edit. Peek → submit → pop only if accepted.
+pub fn tryPromoteQueued(editing: bool) bool {
+ if (!submit_queue.canPromote(.{
+ .editing = editing,
+ .busy = lifecycle == .busy,
+ .has_pending_submit = has_pending_submit,
+ .has_pending_load_earlier = has_pending_load_earlier,
+ .count = submit_queue.count(&queue),
+ })) return false;
+ return submit_queue.promoteIf(&queue, promoteSubmit);
+}
+
+fn promoteSubmit(text: []const u8) bool {
+ queueSubmitFromUi(text);
+ return has_pending_submit;
+}
+
+pub fn queuedCount() u32 {
+ return submit_queue.count(&queue);
+}
+
+pub fn queuedItemAt(i: u32) ?[]const u8 {
+ return submit_queue.item(&queue, i);
+}
+
+pub fn clearSubmitQueue() void {
+ submit_queue.clear(&queue);
+ refresh();
+}
+
+pub fn removeQueuedAt(i: u32) void {
+ submit_queue.removeAt(&queue, i);
+ refresh();
+}
+
+pub fn replaceQueuedAt(i: u32, text: []const u8) bool {
+ submit_queue.replaceAt(&queue, i, text) catch return false;
+ refresh();
+ return true;
+}
+
/// User Stop — discard unacked submit and signal host to abort. Does not set Ready.
pub fn queueCancelFromUi() void {
if (lifecycle != .busy and !has_pending_submit) return;
@@ -247,6 +299,7 @@ pub fn reset() void {
echo_len = 0;
has_pending_submit = false;
pending_submit_len = 0;
+ submit_queue.clear(&queue);
can_load_earlier = false;
has_pending_load_earlier = false;
has_pending_cancel = false;
@@ -473,6 +526,7 @@ export fn inv_clear_messages() void {
// Hydrate / New must not leave a queued Send from the previous session.
has_pending_submit = false;
pending_submit_len = 0;
+ submit_queue.clear(&queue);
image_cache.clear();
math_cache.clear();
refresh();
@@ -516,6 +570,11 @@ export fn inv_ack_pending_submit() void {
refresh();
}
+/// Protocol v18 — host / future auto-continue reads the ephemeral queue depth.
+export fn inv_queued_count() u32 {
+ return submit_queue.count(&queue);
+}
+
export fn inv_set_can_load_earlier(v: u8) void {
const next = v != 0;
if (can_load_earlier == next) return;
diff --git a/native/harness/src/queue_preview.test.zig b/native/harness/src/queue_preview.test.zig
new file mode 100644
index 00000000..5daea799
--- /dev/null
+++ b/native/harness/src/queue_preview.test.zig
@@ -0,0 +1,77 @@
+//! Host unit tests for `queue_preview.zig` (plan #664).
+const std = @import("std");
+const t = std.testing;
+const qp = @import("queue_preview.zig");
+
+const BUF_LEN = qp.QUEUE_PREVIEW_MAX_BYTES + 1;
+
+fn preview(buf: []u8, text: []const u8) []const u8 {
+ const p: *[BUF_LEN]u8 = buf[0..BUF_LEN];
+ return qp.queuePreview(p, text);
+}
+
+test "keeps slash+body (does not strip)" {
+ var buf: [BUF_LEN]u8 = undefined;
+ try t.expectEqualStrings("/skill-name explain this", preview(&buf, "/skill-name explain this"));
+}
+
+test "first line only" {
+ var buf: [BUF_LEN]u8 = undefined;
+ try t.expectEqualStrings("hello", preview(&buf, "hello\nworld"));
+}
+
+test "empty" {
+ var buf: [BUF_LEN]u8 = undefined;
+ try t.expectEqualStrings("", preview(&buf, ""));
+}
+
+test "newline at start returns empty" {
+ var buf: [BUF_LEN]u8 = undefined;
+ try t.expectEqualStrings("", preview(&buf, "\nmore"));
+}
+
+test "caps at 100 bytes UTF-8 safe" {
+ var buf: [BUF_LEN]u8 = undefined;
+ var long: [120]u8 = undefined;
+ @memset(long[0..], 'a');
+ const got = preview(&buf, &long);
+ try t.expectEqual(@as(usize, qp.QUEUE_PREVIEW_MAX_BYTES), got.len);
+}
+
+test "UTF-8 back-off does not split a codepoint" {
+ var buf: [BUF_LEN]u8 = undefined;
+ // Build a string >100 bytes where byte 100 (the cap boundary) falls in
+ // the middle of a 3-byte UTF-8 codepoint (€ = 0xE2 0x82 0xAC).
+ // 33 × "ab" = 66 bytes → then pad with € (3 bytes each).
+ // 66 + 11×3 + 1 = 66+33+1 = 100; byte index 100 is the 2nd byte of
+ // the 12th €, which is a continuation byte (0x82). The back-off must
+ // strip back to byte 99 (end of the 11th complete €).
+ var src: [120]u8 = [_]u8{'a'} ** 120;
+ var pos: usize = 0;
+ // 33 × "ab" = 66 one-byte chars
+ var j: usize = 0;
+ while (j < 33) : (j += 1) {
+ src[pos] = 'a';
+ pos += 1;
+ src[pos] = 'b';
+ pos += 1;
+ }
+ // pad with € (U+20AC = 0xE2 0x82 0xAC, 3 bytes) until >100 bytes
+ while (pos < 110) : (pos += 3) {
+ src[pos] = 0xE2;
+ src[pos + 1] = 0x82;
+ src[pos + 2] = 0xAC;
+ }
+ const got = preview(&buf, src[0..]);
+ try t.expect(got.len <= qp.QUEUE_PREVIEW_MAX_BYTES);
+ // The last byte of the preview must not be a UTF-8 start byte (0b11xxxxxx
+ // = 0xC0..0xFF) or a continuation byte (0b10xxxxxx = 0x80..0xBF) — it
+ // must be a complete single-byte ASCII or the last byte of a complete
+ // multi-byte sequence. In practice the 100th byte is a continuation byte
+ // of €, so back-off strips to 99 (end of the previous full €), which is
+ // 0xAC — the trailing byte of a complete 3-byte sequence — which passes
+ // the start-byte check.
+ if (got.len > 0) {
+ try t.expect((got[got.len - 1] & 0xC0) != 0xC0);
+ }
+}
diff --git a/native/harness/src/queue_preview.zig b/native/harness/src/queue_preview.zig
new file mode 100644
index 00000000..14f7e7a6
--- /dev/null
+++ b/native/harness/src/queue_preview.zig
@@ -0,0 +1,26 @@
+//! First-line preview for a queued operator prompt (plan #664).
+//! Pure — no dvui / slash-stripping. Host-unit-tested via `test-rich`.
+
+const std = @import("std");
+
+pub const QUEUE_PREVIEW_MAX_BYTES: usize = 100;
+
+fn isUtf8Continuation(b: u8) bool {
+ return (b & 0xC0) == 0x80;
+}
+
+/// First line of `text`, capped at `QUEUE_PREVIEW_MAX_BYTES`, UTF-8 safe.
+/// Does **not** strip a leading `/slash` (queued prompts are operator text).
+pub fn queuePreview(buf: *[QUEUE_PREVIEW_MAX_BYTES + 1]u8, text: []const u8) []const u8 {
+ var out: usize = 0;
+ for (text) |c| {
+ if (out >= QUEUE_PREVIEW_MAX_BYTES) break;
+ if (c == '\n' or c == '\r') break;
+ buf[out] = c;
+ out += 1;
+ }
+ while (out > 0 and (buf[out - 1] == ' ' or buf[out - 1] == '\t')) out -= 1;
+ while (out > 0 and isUtf8Continuation(buf[out - 1])) out -= 1;
+ if (out > 0 and (buf[out - 1] & 0xC0) == 0xC0) out -= 1;
+ return buf[0..out];
+}
diff --git a/native/harness/src/submit_queue.test.zig b/native/harness/src/submit_queue.test.zig
new file mode 100644
index 00000000..f6b7a4b8
--- /dev/null
+++ b/native/harness/src/submit_queue.test.zig
@@ -0,0 +1,153 @@
+//! Host unit tests for `submit_queue.zig` (plan #664).
+const std = @import("std");
+const t = std.testing;
+const sq = @import("submit_queue.zig");
+
+test "push happy / peek / pop FIFO" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "one");
+ try sq.push(&q, "two");
+ try sq.push(&q, "three");
+ try t.expectEqual(@as(u32, 3), sq.count(&q));
+ try t.expectEqualStrings("one", sq.peek(&q).?);
+ sq.pop(&q);
+ try t.expectEqualStrings("two", sq.peek(&q).?);
+ sq.pop(&q);
+ try t.expectEqualStrings("three", sq.peek(&q).?);
+ sq.pop(&q);
+ try t.expectEqual(@as(u32, 0), sq.count(&q));
+ try t.expect(sq.peek(&q) == null);
+}
+
+test "push blank rejected" {
+ var q: sq.Q = .{};
+ try t.expectError(error.Blank, sq.push(&q, " \n\t"));
+ try t.expectError(error.Blank, sq.push(&q, ""));
+ try t.expectEqual(@as(u32, 0), sq.count(&q));
+}
+
+test "push full rejects 17th" {
+ var q: sq.Q = .{};
+ var i: usize = 0;
+ while (i < sq.MAX_ITEMS) : (i += 1) {
+ try sq.push(&q, "x");
+ }
+ try t.expectError(error.Full, sq.push(&q, "overflow"));
+ try t.expectEqual(@as(u32, sq.MAX_ITEMS), sq.count(&q));
+}
+
+test "removeAt middle compact" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "a");
+ try sq.push(&q, "b");
+ try sq.push(&q, "c");
+ sq.removeAt(&q, 1);
+ try t.expectEqual(@as(u32, 2), sq.count(&q));
+ try t.expectEqualStrings("a", sq.item(&q, 0).?);
+ try t.expectEqualStrings("c", sq.item(&q, 1).?);
+}
+
+test "removeAt head is pop" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "a");
+ try sq.push(&q, "b");
+ sq.removeAt(&q, 0);
+ try t.expectEqualStrings("b", sq.peek(&q).?);
+}
+
+test "replaceAt updates; blank / bad index rejected" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "old");
+ try sq.replaceAt(&q, 0, "new");
+ try t.expectEqualStrings("new", sq.peek(&q).?);
+ try t.expectError(error.Blank, sq.replaceAt(&q, 0, " "));
+ try t.expectEqualStrings("new", sq.peek(&q).?);
+ try t.expectError(error.BadIndex, sq.replaceAt(&q, 3, "nope"));
+}
+
+test "clear empties" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "a");
+ try sq.push(&q, "b");
+ sq.clear(&q);
+ try t.expectEqual(@as(u32, 0), sq.count(&q));
+ try t.expect(sq.peek(&q) == null);
+}
+
+test "CRLF normalizes on push" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "hello\r\nworld");
+ try t.expectEqualStrings("hello\nworld", sq.peek(&q).?);
+}
+
+test "canPromote truth table" {
+ const yes = sq.canPromote(.{
+ .editing = false,
+ .busy = false,
+ .has_pending_submit = false,
+ .has_pending_load_earlier = false,
+ .count = 1,
+ });
+ try t.expect(yes);
+ try t.expect(!sq.canPromote(.{
+ .editing = true,
+ .busy = false,
+ .has_pending_submit = false,
+ .has_pending_load_earlier = false,
+ .count = 1,
+ }));
+ try t.expect(!sq.canPromote(.{
+ .editing = false,
+ .busy = true,
+ .has_pending_submit = false,
+ .has_pending_load_earlier = false,
+ .count = 1,
+ }));
+ try t.expect(!sq.canPromote(.{
+ .editing = false,
+ .busy = false,
+ .has_pending_submit = true,
+ .has_pending_load_earlier = false,
+ .count = 1,
+ }));
+ try t.expect(!sq.canPromote(.{
+ .editing = false,
+ .busy = false,
+ .has_pending_submit = false,
+ .has_pending_load_earlier = true,
+ .count = 1,
+ }));
+ try t.expect(!sq.canPromote(.{
+ .editing = false,
+ .busy = false,
+ .has_pending_submit = false,
+ .has_pending_load_earlier = false,
+ .count = 0,
+ }));
+}
+
+var g_accept: bool = true;
+var g_seen: usize = 0;
+
+fn acceptSubmit(_: []const u8) bool {
+ g_seen += 1;
+ return g_accept;
+}
+
+test "promoteIf pops only after accept" {
+ var q: sq.Q = .{};
+ try sq.push(&q, "keep");
+ g_accept = false;
+ g_seen = 0;
+ try t.expect(!sq.promoteIf(&q, acceptSubmit));
+ try t.expectEqual(@as(usize, 1), g_seen);
+ try t.expectEqualStrings("keep", sq.peek(&q).?);
+
+ g_accept = true;
+ try t.expect(sq.promoteIf(&q, acceptSubmit));
+ try t.expectEqual(@as(u32, 0), sq.count(&q));
+}
+
+test "ITEM_BYTES matches the live Send cap" {
+ try t.expectEqual(@as(usize, 262144), sq.ITEM_BYTES);
+}
diff --git a/native/harness/src/submit_queue.zig b/native/harness/src/submit_queue.zig
new file mode 100644
index 00000000..ca3b73a0
--- /dev/null
+++ b/native/harness/src/submit_queue.zig
@@ -0,0 +1,119 @@
+//! Wasm-ephemeral FIFO of operator follow-up prompts (plan #664).
+//!
+//! Pure, no dvui / Wasm frame / GPA. Host-unit-tested via `test-rich`.
+//! Per-item storage is `ITEM_BYTES` == `bridge.SUBMIT_CAP` (duplicated so this
+//! module does not import `bridge.zig`, which pulls the web backend).
+
+const std = @import("std");
+const composer_text = @import("composer_text.zig");
+
+/// Generous follow-up list. UI shows 3 rows and scrolls the rest.
+pub const MAX_ITEMS: usize = 16;
+
+/// Must match `bridge.SUBMIT_CAP`. Not a new cap — same clamp as a live Send.
+pub const ITEM_BYTES: usize = 262144;
+
+pub const Slot = struct {
+ len: u32 = 0,
+ data: [ITEM_BYTES]u8 = undefined,
+};
+
+pub const Q = struct {
+ slots: [MAX_ITEMS]Slot = [_]Slot{.{}} ** MAX_ITEMS,
+ head: usize = 0,
+ len: usize = 0,
+};
+
+pub fn count(q: *const Q) u32 {
+ return @intCast(q.len);
+}
+
+pub fn peek(q: *const Q) ?[]const u8 {
+ if (q.len == 0) return null;
+ const s = &q.slots[q.head];
+ return s.data[0..s.len];
+}
+
+pub fn item(q: *const Q, i: usize) ?[]const u8 {
+ if (i >= q.len) return null;
+ const idx = (q.head + i) % MAX_ITEMS;
+ const s = &q.slots[idx];
+ return s.data[0..s.len];
+}
+
+pub fn push(q: *Q, text: []const u8) error{ Blank, Full }!void {
+ if (q.len >= MAX_ITEMS) return error.Full;
+ const idx = (q.head + q.len) % MAX_ITEMS;
+ const s = &q.slots[idx];
+ const norm = composer_text.normalizeInto(text, s.data[0..], ITEM_BYTES);
+ if (norm.is_blank) return error.Blank;
+ s.len = @intCast(norm.text.len);
+ q.len += 1;
+}
+
+/// Drop the head. Does **not** return a view (that would UAF if the slot is reused).
+pub fn pop(q: *Q) void {
+ if (q.len == 0) return;
+ q.slots[q.head].len = 0;
+ q.head = (q.head + 1) % MAX_ITEMS;
+ q.len -= 1;
+}
+
+fn copySlot(dst: *Slot, src: *const Slot) void {
+ dst.len = src.len;
+ if (src.len > 0) {
+ @memcpy(dst.data[0..src.len], src.data[0..src.len]);
+ }
+}
+
+pub fn removeAt(q: *Q, i: usize) void {
+ if (i >= q.len) return;
+ if (i == 0) {
+ pop(q);
+ return;
+ }
+ var j: usize = i;
+ while (j + 1 < q.len) : (j += 1) {
+ const dst_i = (q.head + j) % MAX_ITEMS;
+ const src_i = (q.head + j + 1) % MAX_ITEMS;
+ copySlot(&q.slots[dst_i], &q.slots[src_i]);
+ }
+ q.slots[(q.head + q.len - 1) % MAX_ITEMS].len = 0;
+ q.len -= 1;
+}
+
+pub fn replaceAt(q: *Q, i: usize, text: []const u8) error{ Blank, BadIndex }!void {
+ if (i >= q.len) return error.BadIndex;
+ const idx = (q.head + i) % MAX_ITEMS;
+ const s = &q.slots[idx];
+ var tmp: [ITEM_BYTES]u8 = undefined;
+ const norm = composer_text.normalizeInto(text, tmp[0..], ITEM_BYTES);
+ if (norm.is_blank) return error.Blank;
+ @memcpy(s.data[0..norm.text.len], norm.text);
+ s.len = @intCast(norm.text.len);
+}
+
+pub fn clear(q: *Q) void {
+ q.head = 0;
+ q.len = 0;
+ for (&q.slots) |*s| s.len = 0;
+}
+
+pub fn canPromote(args: struct {
+ editing: bool,
+ busy: bool,
+ has_pending_submit: bool,
+ has_pending_load_earlier: bool,
+ count: u32,
+}) bool {
+ return !args.editing and !args.busy and !args.has_pending_submit and
+ !args.has_pending_load_earlier and args.count > 0;
+}
+
+/// If `submit` returns true, pop the head. Used by `bridge.tryPromoteQueued`.
+pub fn promoteIf(q: *Q, submit: *const fn ([]const u8) bool) bool {
+ const text = peek(q) orelse return false;
+ if (!submit(text)) return false;
+ pop(q);
+ return true;
+}
diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig
index de349a61..98e4c869 100644
--- a/native/harness/src/ui.zig
+++ b/native/harness/src/ui.zig
@@ -26,6 +26,7 @@ const chip = @import("ui/chip.zig");
const status = @import("ui/status.zig");
const skill = @import("ui/skill.zig");
const composer = @import("ui/composer.zig");
+const queue_band = @import("ui/queue_band.zig");
/// Baked at compile time (`-Dbuild-id=…`); shown in header to detect stale wasm.
pub const BUILD_ID: []const u8 = build_options.build_id;
@@ -47,7 +48,7 @@ pub fn onDeinit() void {}
pub fn frame() !void {
const life = bridge.getLifecycle();
- const busy = life == .busy;
+ var busy = life == .busy;
// Full-bleed root. Children that use Options.rect do not report min-size up
// the tree (WidgetData.minSizeReportToParent) — required so tall transcript
@@ -79,10 +80,12 @@ pub fn frame() !void {
// clamped to [IDLE, MAX], so the band hugs the field at idle (~44 px) and
// grows up as lines wrap (adversarial review #584 Round 2 Major L1+L9).
const composer_h = @max(metrics.COMPOSER_IDLE_CHROME_H, @min(state.composer_last_h, metrics.COMPOSER_MAX_CHROME_H));
+ const queue_band_h = @max(0, state.prev_queue_band_h);
const status_y = avail.h - metrics.STATUS_BAR_H;
const composer_y = status_y - composer_h;
+ const queue_band_y = composer_y - queue_band_h;
const scroll_y: f32 = 0;
- const scroll_h: f32 = @max(metrics.SCROLL_FLOOR_H, composer_y - scroll_y);
+ const scroll_h: f32 = @max(metrics.SCROLL_FLOOR_H, queue_band_y - scroll_y);
// Read pane width *before* paint so a same-frame toggle cannot desync the
// rail rect from the scrollArea x (IMGUI: click takes effect next frame).
const pane_w = transcript_split.paneWidth();
@@ -121,8 +124,19 @@ pub fn frame() !void {
// appending at), not a message count, so membership survives saturation/wrap.
{
const cur_lc: thinking_collapse.Lifecycle = @enumFromInt(@intFromEnum(life));
- state.thinking_collapse_state.onLifecycleTransition(state.prev_lifecycle, cur_lc, bridge.messageHead());
+ const prev_lc = state.prev_lifecycle;
+ state.thinking_collapse_state.onLifecycleTransition(prev_lc, cur_lc, bridge.messageHead());
state.prev_lifecycle = cur_lc;
+ const terminal = cur_lc == .ready or cur_lc == .err;
+ const trigger_a = prev_lc == .busy and terminal;
+ const trigger_b = state.queue_closed_edit and terminal;
+ state.queue_closed_edit = false;
+ const editing = state.queue_editing_index != null;
+ if ((trigger_a or trigger_b) and !editing) {
+ if (bridge.tryPromoteQueued(false)) {
+ busy = true;
+ }
+ }
}
var user_scroll: dvui.Point = .{};
{
@@ -378,6 +392,13 @@ pub fn frame() !void {
@memset(&state.msg_content_y, 0);
state.last_user_slot = null;
state.prev_chip_visible = false;
+ // Clear queue editing state — otherwise an edit open during
+ // New/Clear or a session switch ghosts a band and blocks promote
+ // until an unmarked Escape (adversarial review #666 Major L1).
+ state.queue_editing_index = null;
+ state.prev_queue_band_h = 0;
+ state.queue_closed_edit = false;
+ @memset(&state.queue_edit_buf, 0);
} else if (count_changed or content_grew) {
const newest_is_user = blk: {
if (n == 0) break :blk false;
@@ -411,6 +432,12 @@ pub fn frame() !void {
break :blk msg_y < view_top - metrics.CHIP_VISIBILITY_MARGIN;
} else false;
+ // ── Submit queue band (absolute rect — above composer, below transcript) ──
+ if (queue_band_h > 0) {
+ queue_band.paint(queue_band_y, queue_band_h, avail.w);
+ }
+ state.prev_queue_band_h = queue_band.desiredHeight();
+
// ── Composer chrome (absolute rect — hugs one line, grows up to cap) ───
// Dynamic height: previous-frame measured via composer_last_h, clamped to
// [COMPOSER_IDLE_CHROME_H, COMPOSER_MAX_CHROME_H]. At idle the band is ~44 px
@@ -439,7 +466,7 @@ pub fn frame() !void {
// list and mark it handled — which also stops the widget from inserting
// a stray newline for the submit keystroke.
var composer_submit = false;
- if (!busy) {
+ {
const es = dvui.events();
for (0..es.len) |idx| {
const e = &es[idx];
@@ -513,7 +540,7 @@ pub fn frame() !void {
const content_h = @max(metrics.TOUCH_H, @min(raw_content, metrics.COMPOSER_INPUT_MAX_H));
state.composer_last_h = @max(metrics.COMPOSER_IDLE_CHROME_H, @min(content_h + 2 * metrics.COMPOSER_HUG_PAD, metrics.COMPOSER_MAX_CHROME_H));
if (composer_submit and typed.len > 0) {
- composer.submitText(typed);
+ composer.submitOrEnqueue(typed);
typed = state.prompt_buf[0..0];
state.want_composer_focus = true;
}
@@ -525,7 +552,20 @@ pub fn frame() !void {
// top-bar Busy chip). Idle ▶ = Send (submit when non-empty); Busy ■ =
// Stop/cancel (protocol v9 pending cancel → host abort). Glyphs come
// from the embedded DejaVu Sans Symbols face so they never tofu.
+ // Idle ▶ = Send. Busy ▶ = enqueue + ■ Stop (protocol v9).
if (busy) {
+ if (dvui.button(@src(), "▶", .{}, .{
+ .gravity_y = 1.0,
+ .style = .highlight,
+ .font = chrome.composerIconFont(),
+ .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H },
+ .corners = .round(8),
+ })) {
+ if (typed.len > 0) {
+ composer.submitOrEnqueue(typed);
+ state.want_composer_focus = true;
+ }
+ }
if (dvui.button(@src(), "■", .{}, .{
.gravity_y = 1.0,
.style = .content,
@@ -546,7 +586,7 @@ pub fn frame() !void {
.corners = .round(8),
})) {
if (typed.len > 0) {
- composer.submitText(typed);
+ composer.submitOrEnqueue(typed);
state.want_composer_focus = true;
}
}
diff --git a/native/harness/src/ui/composer.zig b/native/harness/src/ui/composer.zig
index 89d5ab52..6740f65c 100644
--- a/native/harness/src/ui/composer.zig
+++ b/native/harness/src/ui/composer.zig
@@ -21,3 +21,18 @@ pub fn submitText(text: []const u8) void {
bridge.queueSubmitFromUi(norm.text);
clearPrompt();
}
+
+pub fn submitOrEnqueue(text: []const u8) void {
+ const norm = composer_text.normalizeInto(text, state.prompt_buf[0..], bridge.SUBMIT_CAP);
+ if (norm.is_blank) {
+ clearPrompt();
+ return;
+ }
+ if (bridge.getLifecycle() == .busy) {
+ bridge.enqueueFromUi(norm.text) catch return;
+ clearPrompt();
+ return;
+ }
+ bridge.queueSubmitFromUi(norm.text);
+ clearPrompt();
+}
diff --git a/native/harness/src/ui/metrics.zig b/native/harness/src/ui/metrics.zig
index cc56b610..eecd6ae5 100644
--- a/native/harness/src/ui/metrics.zig
+++ b/native/harness/src/ui/metrics.zig
@@ -63,6 +63,9 @@ pub const TE_OVERHEAD: f32 = TE_BORDER_H; // 2
/// Sticky last-user-message chip (plan #645, source issue #339).
pub const CHIP_VISIBILITY_MARGIN: f32 = 8;
+/// Submit-queue band (plan #664): header + up to this many visible rows.
+pub const QUEUE_BAND_MAX_ROWS: u32 = 3;
+
/// Gap (px) added to each slot's measured text width — matches the `margin.w`
/// on each slot textLayout below, so the budget math equals the paint exactly.
pub const STATUS_SLOT_GAP: f32 = 10;
diff --git a/native/harness/src/ui/queue_band.zig b/native/harness/src/ui/queue_band.zig
new file mode 100644
index 00000000..f077d6a1
--- /dev/null
+++ b/native/harness/src/ui/queue_band.zig
@@ -0,0 +1,249 @@
+//! Compact in-canvas submit-queue list above the composer (plan #664).
+const std = @import("std");
+const dvui = @import("dvui");
+const bridge = @import("../bridge.zig");
+const palette = @import("../palette.zig");
+const queue_preview = @import("../queue_preview.zig");
+const submit_queue = @import("../submit_queue.zig");
+const state = @import("state.zig");
+const metrics = @import("metrics.zig");
+const chrome = @import("chrome.zig");
+
+pub fn desiredHeight() f32 {
+ const n = bridge.queuedCount();
+ const editing = state.queue_editing_index != null;
+ if (n == 0 and !editing) return 0;
+ const rows = @min(@as(u32, if (n == 0) 1 else n), metrics.QUEUE_BAND_MAX_ROWS);
+ return metrics.TOUCH_H + @as(f32, @floatFromInt(rows)) * metrics.TOUCH_H;
+}
+
+pub fn paint(band_y: f32, band_h: f32, avail_w: f32) void {
+ if (band_h < 1) return;
+ const n = bridge.queuedCount();
+
+ var band = dvui.box(@src(), .{ .dir = .vertical }, .{
+ .rect = .{ .x = 0, .y = band_y, .w = avail_w, .h = band_h },
+ .background = true,
+ .color_fill = palette.teal_bg,
+ .color_border = palette.teal_border,
+ .padding = .all(0),
+ .border = .{ .x = 0, .y = 1, .w = 0, .h = 0 },
+ });
+ defer band.deinit();
+
+ {
+ var header = dvui.box(@src(), .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .min_size_content = .{ .w = 40, .h = metrics.TOUCH_H },
+ .max_size_content = .{ .w = dvui.max_float_safe, .h = metrics.TOUCH_H },
+ .background = true,
+ .color_fill = palette.teal_bg,
+ .padding = .{ .x = 8, .y = 0, .w = 4, .h = 0 },
+ });
+ defer header.deinit();
+
+ var label_buf: [32]u8 = undefined;
+ const label = std.fmt.bufPrint(&label_buf, "{d} queued", .{n}) catch "queued";
+ dvui.labelNoFmt(@src(), label, .{}, .{
+ .gravity_y = 0.5,
+ .color_text = palette.teal_muted,
+ .expand = .horizontal,
+ });
+ if (n >= @as(u32, @intCast(submit_queue.MAX_ITEMS))) {
+ // Queue at capacity — a further ▶ / Ctrl+Enter is a no-op. EMBER
+ // makes the failed enqueue visible instead of silent (adversarial
+ // review #666, L9 Minor).
+ dvui.labelNoFmt(@src(), "· full", .{}, .{
+ .gravity_y = 0.5,
+ .color_text = palette.ember_text,
+ });
+ }
+ if (n > 0) {
+ if (dvui.button(@src(), "Clear", .{}, .{
+ .gravity_y = 0.5,
+ .style = .content,
+ .min_size_content = .{ .w = 56, .h = metrics.TOUCH_H },
+ .color_fill = palette.teal_surface,
+ .color_text = palette.teal_text,
+ .color_border = palette.teal_border,
+ })) {
+ cancelEdit();
+ bridge.clearSubmitQueue();
+ }
+ }
+ }
+
+ const list_h = @max(0, band_h - metrics.TOUCH_H);
+ var scroll_area = dvui.scrollArea(@src(), .{
+ .scroll_info = &state.queue_list_scroll,
+ .vertical_bar = .auto,
+ }, .{
+ .expand = .horizontal,
+ .min_size_content = .{ .w = 40, .h = list_h },
+ .max_size_content = .{ .w = dvui.max_float_safe, .h = list_h },
+ .padding = .all(0),
+ });
+ defer scroll_area.deinit();
+
+ var i: u32 = 0;
+ while (i < n) : (i += 1) {
+ paintRow(@src(), i);
+ }
+
+ if (state.queue_editing_index != null) {
+ handleEscape();
+ // Blur-save: if the queue-row textEntry lost focus this frame
+ // (e.g. operator clicked the composer), save the edit and close
+ // so promote isn't stalled behind a ghost edit (plan #664, review
+ // #666 Minor L1+L8).
+ const focused = dvui.focusedWidgetIdInCurrentSubwindow();
+ if (state.queue_edit_textentry_id) |te_id| {
+ if (focused == null or (focused.? != te_id)) {
+ const text = std.mem.sliceTo(state.queue_edit_buf[0..], 0);
+ saveEdit(@intCast(state.queue_editing_index.?), text);
+ }
+ }
+ }
+}
+
+fn paintRow(src: std.builtin.SourceLocation, i: u32) void {
+ const editing = if (state.queue_editing_index) |e| e == @as(usize, i) else false;
+ var row = dvui.box(src, .{ .dir = .horizontal }, .{
+ .expand = .horizontal,
+ .min_size_content = .{ .w = 40, .h = metrics.TOUCH_H },
+ .max_size_content = .{ .w = dvui.max_float_safe, .h = metrics.TOUCH_H },
+ .id_extra = i,
+ .padding = .{ .x = 4, .y = 0, .w = 4, .h = 0 },
+ .background = true,
+ .color_fill = palette.teal_surface,
+ });
+ defer row.deinit();
+
+ var typed: []const u8 = state.queue_edit_buf[0..0];
+ if (editing) {
+ var te = dvui.textEntry(@src(), .{
+ .text = .{ .buffer = state.queue_edit_buf[0..] },
+ .multiline = false,
+ }, .{
+ .expand = .horizontal,
+ .gravity_y = 0.5,
+ .min_size_content = .{ .w = 80, .h = metrics.TOUCH_H - 8 },
+ .color_fill = palette.teal_bg,
+ .color_text = palette.teal_text,
+ .color_border = palette.teal_accent,
+ .id_extra = i,
+ });
+ typed = te.getText();
+ state.queue_edit_textentry_id = te.data().id;
+ te.deinit();
+ if (submitChord()) {
+ saveEdit(i, typed);
+ }
+ } else {
+ var preview_buf: [queue_preview.QUEUE_PREVIEW_MAX_BYTES + 1]u8 = undefined;
+ const raw = bridge.queuedItemAt(i) orelse "";
+ const preview = queue_preview.queuePreview(&preview_buf, raw);
+ dvui.labelNoFmt(@src(), preview, .{}, .{
+ .gravity_y = 0.5,
+ .color_text = palette.teal_text,
+ .expand = .horizontal,
+ .id_extra = i,
+ });
+ }
+
+ if (dvui.button(@src(), "✎", .{}, .{
+ .gravity_y = 0.5,
+ .style = .content,
+ .font = chrome.composerIconFont(),
+ .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H },
+ .color_fill = palette.teal_bg,
+ .color_text = palette.teal_accent,
+ .color_border = palette.teal_border,
+ .id_extra = i,
+ })) {
+ if (editing) {
+ saveEdit(i, typed);
+ } else {
+ beginEdit(i);
+ }
+ }
+ if (dvui.button(@src(), "×", .{}, .{
+ .gravity_y = 0.5,
+ .style = .content,
+ .font = chrome.composerIconFont(),
+ .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H },
+ .color_fill = palette.teal_bg,
+ .color_text = palette.teal_muted,
+ .color_border = palette.teal_border,
+ .id_extra = i,
+ })) {
+ if (state.queue_editing_index) |e| {
+ if (e == @as(usize, i)) {
+ cancelEdit();
+ } else if (e > i) {
+ state.queue_editing_index = e - 1;
+ }
+ }
+ bridge.removeQueuedAt(i);
+ }
+}
+
+fn beginEdit(i: u32) void {
+ const text = bridge.queuedItemAt(i) orelse return;
+ @memset(&state.queue_edit_buf, 0);
+ const n = @min(text.len, state.queue_edit_buf.len);
+ if (n > 0) @memcpy(state.queue_edit_buf[0..n], text[0..n]);
+ state.queue_editing_index = i;
+}
+
+fn saveEdit(i: u32, typed: []const u8) void {
+ if (bridge.replaceQueuedAt(i, typed)) {
+ closeEdit();
+ }
+}
+
+fn cancelEdit() void {
+ if (state.queue_editing_index != null) closeEdit();
+}
+
+fn closeEdit() void {
+ state.queue_editing_index = null;
+ state.queue_edit_textentry_id = null;
+ @memset(&state.queue_edit_buf, 0);
+ state.queue_closed_edit = true;
+}
+
+fn submitChord() bool {
+ const es = dvui.events();
+ var hit = false;
+ for (0..es.len) |idx| {
+ const e = &es[idx];
+ if (e.handled) continue;
+ const ke = switch (e.evt) {
+ .key => |k| k,
+ else => continue,
+ };
+ if (ke.code == .enter and (ke.mod.control() or ke.mod.command())) {
+ e.handled = true;
+ if (ke.action == .down) hit = true;
+ }
+ }
+ return hit;
+}
+
+fn handleEscape() void {
+ const es = dvui.events();
+ for (0..es.len) |idx| {
+ const e = &es[idx];
+ if (e.handled) continue;
+ const ke = switch (e.evt) {
+ .key => |k| k,
+ else => continue,
+ };
+ if (ke.code == .escape and ke.action == .down) {
+ e.handled = true;
+ cancelEdit();
+ return;
+ }
+ }
+}
diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig
index ea49e893..c30ab296 100644
--- a/native/harness/src/ui/state.zig
+++ b/native/harness/src/ui/state.zig
@@ -79,6 +79,23 @@ pub var last_user_slot: ?usize = null;
/// and chip paint this frame. One-frame settle (same pattern as composer_last_h).
pub var prev_chip_visible: bool = false;
+/// Queue-row being edited, or null. Held promote while non-null.
+pub var queue_editing_index: ?usize = null;
+/// One-frame settle for the queue band height (same pattern as chip / composer).
+pub var prev_queue_band_h: f32 = 0;
+/// Set when an edit is saved or cancelled — Trigger B for `tryPromoteQueued`.
+pub var queue_closed_edit: bool = false;
+/// Scratch buffer for the in-band queue-row editor.
+pub var queue_edit_buf: [bridge.SUBMIT_CAP]u8 = [_]u8{0} ** bridge.SUBMIT_CAP;
+/// dvui widget id of the active queue-row textEntry (set during paintRow
+/// while editing; cleared on close). Used for blur-save detection — if the
+/// textEntry loses focus while queue_editing_index is set, we save-and-close.
+pub var queue_edit_textentry_id: ?dvui.Id = null;
+pub var queue_list_scroll: dvui.ScrollInfo = .{
+ .vertical = .auto,
+ .horizontal = .none,
+};
+
pub fn resetTranscriptScroll() void {
transcript_scroll = .{
.vertical = .auto,
@@ -94,4 +111,13 @@ pub fn resetTranscriptScroll() void {
@memset(&msg_content_y, 0);
last_user_slot = null;
prev_chip_visible = false;
+ queue_editing_index = null;
+ queue_edit_textentry_id = null;
+ prev_queue_band_h = 0;
+ queue_closed_edit = false;
+ @memset(&queue_edit_buf, 0);
+ queue_list_scroll = .{
+ .vertical = .auto,
+ .horizontal = .none,
+ };
}