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, + }; }