feat(desktop): per-session mid-turn message queue (enqueue-on-inflight, drain-once-per-turn, strip UI)

This commit is contained in:
ZiyaZhang
2026-07-20 01:59:23 -07:00
parent 0207b5edd2
commit 830579ab50
12 changed files with 1052 additions and 8 deletions
+11
View File
@@ -104,6 +104,17 @@ a toggle), and `context/message` / `steering/message` events surface as
and why. A `Cancel` button appears mid-turn and cuts the stream via
`session/cancel`.
The wire accepts only one in-flight prompt per session, so pressing Enter
while a turn is running **queues** your message instead of erroring: a strip
above the composer shows one chip per queued message with a `queued N`
counter, and each chip lets you edit the text inline, delete it, or bump it to
the front (**send next**). When the turn ends, the head of the queue is sent
automatically — one message per turn completion — so a burst of follow-ups
plays out in order without you babysitting each turn. The queue is
per-session (switching sessions shows that session's queue), survives a
cancelled turn, and is cleared with a notice if the runtime restarts or you
switch profiles.
![](docs/readme-shots/03-chat.png)
#### Session Tree
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,243 @@
// QA verification script for lane-msg-queue. Boots an isolated Electron on
// its own CDP port, drives the composer message queue entirely through the
// renderer's `window.__dshRenderer` seam (no live model needed), and captures
// two screenshots into docs/qa-msg-queue/:
//
// 01-inflight-two-queued.png — a turn in flight with TWO messages queued;
// the strip shows two chips + a "queued 2" counter.
// 02-after-turn-end-one-left.png — after turn/end auto-drains the head, the
// first queued message appears as a sent bubble and the strip shows one
// remaining chip ("queued 1").
//
// Isolation follows the 2026-07-18 postmortem baked into
// scripts/qa-cdp-shoot-nav-optional.mjs:
// 1. --user-data-dir=<tmp> isolates Chromium userdata.
// 2. DSH_DESKTOP_HOME=<tmp> isolates the main-process config root so we
// never write into ~/.dsh-desktop.
// 3. own CDP port (≥9290) so a lingering Electron helper from another
// lane's shoot can't hijack our DevTools endpoint.
//
// Why drive via the seam and not a real prompt: the message-queue behaviour
// is a pure renderer concern (enqueue-on-inflight + auto-drain on turn/end).
// `window.__dshRenderer` exposes send() + onSessionEvent() + listMsgQueue()
// ungated, so we can create an in-flight turn, enqueue two messages, and step
// the turn to completion deterministically — no daemon round-trip, no model
// key. The keyless daemon-echo include still boots the runtime so the shell
// is in its normal chat state.
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
import { resolve, join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
import { tmpdir } from 'node:os'
const WORKTREE = resolve(process.env.DSH_WORKTREE || process.cwd())
const PARENT = resolve(process.env.DSH_REPO || '/Users/ziya/harness/dsh-desktop-demo')
const ELECTRON = join(PARENT, 'node_modules/.bin/electron')
const CDP_PORT = Number(process.env.DSH_MSG_QUEUE_PORT || 9290)
const OUTDIR = join(WORKTREE, 'docs/qa-msg-queue')
if (!existsSync(ELECTRON)) {
console.error(`electron binary not found at ${ELECTRON}`)
process.exit(2)
}
mkdirSync(OUTDIR, { recursive: true })
function seedHome(dshHome) {
// Minimal keyless overlay + config + onboarded sentinel so the first-run
// modal doesn't fire and steal the window / rewrite our seed. daemon-echo
// is the keyless demo runtime; we never send a real prompt through it.
const seedOverlay = [
'# QA msg-queue shoot seed overlay (tmp, per-run).',
'plugins:',
` - "@cordisjs/plugin-include":`,
` path: ${join(WORKTREE, 'config/daemon-echo.yml')}`,
'',
].join('\n')
writeFileSync(join(dshHome, 'user-overlay.cordis.yml'), seedOverlay)
writeFileSync(join(dshHome, 'config.json'), JSON.stringify({ role: 'coding', approvalMode: 'never' }, null, 2))
writeFileSync(join(dshHome, '.onboarded'), new Date().toISOString())
}
async function bootElectron(dshHome, userData, port) {
const child = spawn(ELECTRON, [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userData}`,
'--disable-gpu',
'--no-sandbox',
'.',
], {
cwd: WORKTREE,
env: { ...process.env, DSH_DESKTOP_HOME: dshHome, DSH_MAXIMIZE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
const logs = []
child.stdout.on('data', d => logs.push(String(d)))
child.stderr.on('data', d => logs.push(String(d)))
for (let i = 0; i < 40; i++) {
await sleep(500)
try {
const r = await fetch(`http://localhost:${port}/json/list`)
if (r.ok) return { child, logs }
} catch {}
}
child.kill('SIGKILL')
console.error('electron CDP did not come up in 20s. logs:\n' + logs.join(''))
process.exit(3)
}
async function newCdp(port) {
const targets = await (await fetch(`http://localhost:${port}/json/list`)).json()
const target = targets.find(t => t.type === 'page')
if (!target) throw new Error('no page target on port ' + port)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((ok, err) => { ws.onopen = ok; ws.onerror = e => err(e) })
let id = 1
const pending = new Map()
ws.onmessage = ev => {
const data = typeof ev.data === 'string' ? ev.data : String(ev.data)
let msg
try { msg = JSON.parse(data) } catch { return }
if (msg.id != null && pending.has(msg.id)) {
const [ok, err] = pending.get(msg.id); pending.delete(msg.id)
if (msg.error) err(new Error(msg.error.message)); else ok(msg.result)
}
}
const call = (m, p = {}, ms = 15000) => new Promise((ok, err) => {
const _id = id++
const t = setTimeout(() => { pending.delete(_id); err(new Error('cdp timeout: ' + m)) }, ms)
pending.set(_id, [v => { clearTimeout(t); ok(v) }, e => { clearTimeout(t); err(e) }])
ws.send(JSON.stringify({ id: _id, method: m, params: p }))
})
const evj = async expr => {
const r = await call('Runtime.evaluate', {
expression: `(async()=>{try{return (${expr})}catch(e){return {__err:String(e)}}})()`,
returnByValue: true, awaitPromise: true,
})
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text)
return r.result?.value
}
return { ws, call, evj }
}
// Clip to the composer footer so the strip is unmistakably in frame (not a
// giant empty chat pane). Falls back to a full-page shot if the selector
// isn't found.
async function shoot(call, evj, name) {
const clip = await evj(`
(() => {
const f = document.querySelector('.composer.composer-shell')
if (!f) return null
const r = f.getBoundingClientRect()
// pad upward so a freshly-sent bubble just above the composer is caught.
const top = Math.max(0, r.y - 220)
return { x: r.x, y: top, width: r.width, height: (r.bottom - top) + 12, scale: 1 }
})()
`)
const shotArgs = { format: 'png', captureBeyondViewport: true }
if (clip) shotArgs.clip = clip
const shot = await call('Page.captureScreenshot', shotArgs, 30000)
if (!shot || !shot.data) throw new Error('captureScreenshot returned no data')
const outPath = join(OUTDIR, name)
writeFileSync(outPath, Buffer.from(shot.data, 'base64'))
const bytes = statSync(outPath).size
console.log(` wrote ${outPath} (${bytes} bytes)`)
if (bytes < 20000) throw new Error(`screenshot ${name} is only ${bytes} bytes (<20KB) — likely blank`)
return { path: outPath, bytes }
}
async function main() {
const dshHome = join(tmpdir(), 'dsh-msg-queue-home')
const userData = join(tmpdir(), 'dsh-msg-queue-userdata')
for (const dir of [dshHome, userData]) {
try { rmSync(dir, { recursive: true, force: true }) } catch {}
mkdirSync(dir, { recursive: true })
}
seedHome(dshHome)
console.log(`booting on CDP :${CDP_PORT}`)
const { child } = await bootElectron(dshHome, userData, CDP_PORT)
try {
await sleep(1500)
const { call, evj } = await newCdp(CDP_PORT)
await call('Page.enable')
// Step 1: create a session, make it active, and open an in-flight turn.
// We drive the renderer directly through the always-exposed seam.
const setup = await evj(`
(async () => {
const R = window.__dshRenderer
if (!R) return { __err: 'no __dshRenderer seam' }
R.ensureSession('qa-mq', { title: 'Message queue demo', header: {}, hasUserMessage: true })
await R.selectSession('qa-mq')
R.onSessionEvent('qa-mq', { type: 'turn/start', seq: 1 })
// Enqueue two follow-ups via the real composer send() path.
const input = document.getElementById('input')
input.value = 'Summarise the three failing tests'
await R.send()
input.value = 'Then open a PR with the fix'
await R.send()
return { active: R.getActiveSessionId(), queued: R.listMsgQueue('qa-mq').map(x => x.text) }
})()
`)
console.log(' setup:', JSON.stringify(setup))
if (setup && setup.__err) throw new Error(setup.__err)
if (!setup || setup.queued.length !== 2) throw new Error('expected 2 queued messages, got ' + JSON.stringify(setup))
// Assert the strip is actually visible with two chips before shooting.
const stripA = await evj(`
(() => {
const s = document.getElementById('msg-queue-strip')
return {
hidden: s.hidden,
chips: s.querySelectorAll('.msg-queue-chip').length,
counter: (s.querySelector('.msg-queue-count') || {}).textContent || '',
}
})()
`)
console.log(' strip (inflight):', JSON.stringify(stripA))
if (stripA.hidden || stripA.chips !== 2) throw new Error('strip not showing 2 chips: ' + JSON.stringify(stripA))
await sleep(300)
const shotA = await shoot(call, evj, '01-inflight-two-queued.png')
// Step 2: end the turn. turn/end auto-drains exactly one item — the head
// renders as an optimistic user bubble and the strip drops to one chip.
const drained = await evj(`
(async () => {
const R = window.__dshRenderer
R.onSessionEvent('qa-mq', { type: 'turn/end', seq: 2 })
await new Promise(r => setTimeout(r, 60))
const bubbles = Array.from(document.querySelectorAll('#stream .msg.user .role-label')).length
return { remaining: R.listMsgQueue('qa-mq').map(x => x.text), userBubbles: bubbles }
})()
`)
console.log(' after turn/end:', JSON.stringify(drained))
if (!drained || drained.remaining.length !== 1) throw new Error('expected 1 remaining, got ' + JSON.stringify(drained))
const stripB = await evj(`
(() => {
const s = document.getElementById('msg-queue-strip')
return {
hidden: s.hidden,
chips: s.querySelectorAll('.msg-queue-chip').length,
counter: (s.querySelector('.msg-queue-count') || {}).textContent || '',
}
})()
`)
console.log(' strip (after drain):', JSON.stringify(stripB))
if (stripB.hidden || stripB.chips !== 1) throw new Error('strip not showing 1 chip after drain: ' + JSON.stringify(stripB))
await sleep(300)
const shotB = await shoot(call, evj, '02-after-turn-end-one-left.png')
console.log('\n--- SUMMARY ---')
console.log(`inflight (2 queued): ${shotA.path} (${shotA.bytes} bytes)`)
console.log(`after drain (1 left): ${shotB.path} (${shotB.bytes} bytes)`)
} finally {
try { child.kill('SIGKILL') } catch {}
for (let i = 0; i < 20; i++) {
await sleep(500)
try { await fetch(`http://localhost:${CDP_PORT}/json/list`) } catch { break }
}
}
}
main().catch(err => { console.error(err); process.exit(1) })
+8
View File
@@ -517,6 +517,13 @@
matching the widget action catalog (see next-actions.js).
Empty by default; hidden entirely when no suggestions land. -->
<div id="next-action-chips" class="next-action-chips" hidden></div>
<!-- Message queue strip (lane-msg-queue). Hidden until the active
session has queued messages. Renderer's renderMsgQueueStrip
fills it with one chip per queued message (truncated text,
inline-edit, delete, "send next") + a "queued N" counter.
Per-session: switching sessions re-renders from that session's
queue. See msg-queue-model.js + renderer.js §"message queue". -->
<div id="msg-queue-strip" class="msg-queue-strip" hidden aria-live="polite"></div>
<div class="composer-frame">
<textarea id="input" rows="3" placeholder="Message DSH… (Enter to send, Shift+Enter for newline)"></textarea>
<div class="composer-bar">
@@ -1487,6 +1494,7 @@
drill-down view models. Loaded before subagent-view.js so
buildInlineSubagentTrace can pick up the drill-down helpers. -->
<script src="./compact-config-model.js"></script>
<script src="./msg-queue-model.js"></script>
<script src="./subagent-drilldown.js"></script>
<script src="./context-rail.js"></script>
<script src="./workflow-view.js"></script>
@@ -0,0 +1,142 @@
// Pure model for the composer message queue (lane-msg-queue).
//
// The DSH wire accepts exactly ONE in-flight prompt per session — a second
// session/prompt while a turn is running fails on the wire. So when the user
// hits Enter mid-turn we don't drop the text and we don't error: we park it
// in a per-session FIFO and auto-send the head when the turn completes.
//
// This module is the data structure only — no DOM, no wire. The renderer
// owns the "when to enqueue vs send" decision and the "drain on turn end"
// timing; this file just holds the queues and enforces their invariants so
// the ordering/isolation semantics can be locked in `node --test` without an
// Electron harness.
//
// Shape:
// queues: Map<sessionId, Array<{ id, text }>>
// Item ids are monotonic across the whole module (not per-session) so a
// chip's id is globally unique — the UI keys DOM nodes on it and never has
// to disambiguate by session.
//
// Invariants the renderer relies on:
// - FIFO: enqueue appends to the tail; drain pops the head.
// - promote(id) moves that item to the head (the "send next" affordance),
// so the very next drain sends it regardless of arrival order.
// - Per-session isolation: no operation on session A can read, mutate, or
// drain session B. A drain of an empty/unknown session returns null.
// - Empty / whitespace-only text never enqueues (returns null) — a blank
// chip is a trap the user can't tell apart from a real queued message.
'use strict'
function createMsgQueue() {
/** @type {Map<string, Array<{id:number,text:string}>>} */
const queues = new Map()
let _seq = 0
function nextId() { _seq += 1; return _seq }
function ensure(sessionId) {
let q = queues.get(sessionId)
if (!q) { q = []; queues.set(sessionId, q) }
return q
}
// Append `text` to sessionId's queue. Returns the new item id, or null if
// the text is empty/whitespace-only (never enqueue a blank).
function enqueue(sessionId, text) {
if (sessionId == null) return null
const trimmed = typeof text === 'string' ? text.trim() : ''
if (!trimmed) return null
const id = nextId()
ensure(sessionId).push({ id, text: trimmed })
return id
}
// Drop the item with `id` from sessionId's queue. Returns true if removed.
function remove(sessionId, id) {
const q = queues.get(sessionId)
if (!q) return false
const i = q.findIndex((it) => it.id === id)
if (i < 0) return false
q.splice(i, 1)
return true
}
// Rewrite the text of an existing queued item. Empty/whitespace text is
// rejected (returns false) — the caller should treat that as "delete" if
// it wants blank edits to remove the chip. Returns true on success.
function update(sessionId, id, text) {
const q = queues.get(sessionId)
if (!q) return false
const trimmed = typeof text === 'string' ? text.trim() : ''
if (!trimmed) return false
const it = q.find((x) => x.id === id)
if (!it) return false
it.text = trimmed
return true
}
// Move `id` to the head of its session queue (the "send next" affordance).
// No-op returning false if the item isn't found; a single-item or
// already-head move succeeds (idempotent) and returns true.
function promote(sessionId, id) {
const q = queues.get(sessionId)
if (!q) return false
const i = q.findIndex((it) => it.id === id)
if (i < 0) return false
if (i === 0) return true
const [it] = q.splice(i, 1)
q.unshift(it)
return true
}
// Pop and return the head item ({id,text}) of sessionId's queue, or null
// when the queue is empty/unknown. This is the single "send one on turn
// end" primitive — the renderer calls it exactly once per turn completion.
function drain(sessionId) {
const q = queues.get(sessionId)
if (!q || q.length === 0) return null
return q.shift()
}
// Return a shallow copy of sessionId's queue (safe to render from; callers
// can't mutate the live array). Empty array for an unknown session.
function list(sessionId) {
const q = queues.get(sessionId)
return q ? q.map((it) => ({ id: it.id, text: it.text })) : []
}
function size(sessionId) {
const q = queues.get(sessionId)
return q ? q.length : 0
}
// Empty one session's queue. Returns the number of items dropped.
function clear(sessionId) {
const q = queues.get(sessionId)
if (!q) return 0
const n = q.length
queues.delete(sessionId)
return n
}
// Empty every session's queue (runtime crash / profile switch — the old
// session ids are a different namespace against the restarted daemon, so
// holding their queued text would send it into the void). Returns total
// items dropped across all sessions.
function clearAll() {
let n = 0
for (const q of queues.values()) n += q.length
queues.clear()
return n
}
return { enqueue, remove, update, promote, drain, list, size, clear, clearAll }
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { createMsgQueue }
}
if (typeof window !== 'undefined') {
window.__dshMsgQueueModel = { createMsgQueue }
}
+228 -8
View File
@@ -99,6 +99,19 @@ const statusText = document.getElementById('status-text')
const modelBadge = document.getElementById('model-badge')
const profileSelect = document.getElementById('profile')
const newSessionBtn = document.getElementById('new-session')
const msgQueueStripEl = document.getElementById('msg-queue-strip')
// Composer message queue (lane-msg-queue). The wire accepts one in-flight
// prompt per session; a mid-turn Enter would otherwise fire a second
// session/prompt that the daemon rejects. Instead we park the text in a
// per-session FIFO (msg-queue-model.js) and auto-drain the head when the
// turn completes. The model is a pure data structure; all DOM + wire timing
// lives in this file (see §"message queue"). Guarded so a stripped build
// without the module script simply disables queueing (send stays direct).
const msgQueue = (typeof window !== 'undefined' && window.__dshMsgQueueModel
&& typeof window.__dshMsgQueueModel.createMsgQueue === 'function')
? window.__dshMsgQueueModel.createMsgQueue()
: null
const state = {
activeSessionId: null,
@@ -586,6 +599,9 @@ async function selectSession(id) {
// their seed boundary. During live streaming we grow the same map when
// subagent.started fires.
installKnownForkMarkers(id)
// Per-session queue: repaint the strip for the session we just switched to.
// Strict isolation — the strip only ever shows the active session's queue.
renderMsgQueueStrip()
}
async function replayHistory(id) {
@@ -4856,6 +4872,11 @@ function onSessionEvent(sessionId, event) {
// without a badge instead of one from the previous turn.
const startData = event.data || event
meta.currentTurnTrigger = (startData && startData.trigger) || null
// Arm the once-per-turn queue drain. turn/end and session.finished both
// signal completion (a clean turn emits turn/end; an errored/cancelled
// turn may arrive only as session.finished). Whichever fires first
// consumes this flag so the queue drains exactly one item per turn.
meta._turnDrainPending = true
if (sessionId === state.activeSessionId) { state.inflightTurn = true; updateCancelButton(); updateCompactButton(); updateForkButtons() }
renderSessionList()
}
@@ -4864,6 +4885,11 @@ function onSessionEvent(sessionId, event) {
meta.currentTurnTrigger = null
if (sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateCompactButton(); updateForkButtons() }
renderSessionList()
// Auto-drain one queued message for this session now that its turn is
// done. Fires for the exact turn-completion condition that re-enables
// Cancel; a cancelled turn ends with turn/end too, so the queue drains
// there just like a clean completion (queue survives Cancel by design).
void drainMsgQueueOnce(sessionId)
}
if (event.time && event.time > meta.lastEventTime) meta.lastEventTime = event.time
// §1.1 trace bucket: non-boundary events get bucketed into
@@ -6071,9 +6097,133 @@ function onInterruptInvalidate({ interruptId, reason }) {
// -- send / cancel -----------------------------------------------------------
// -- message queue ----------------------------------------------------------
//
// Renders the strip above the composer for the ACTIVE session's queue. The
// strip is hidden when the queue is empty; each queued message is one chip
// with truncated text (click to inline-edit), a "send next" (promote) button,
// and a delete (×). A "queued N" counter badge leads the row. Per-session:
// selectSession + every mutation calls this, so switching sessions shows that
// session's queue and nothing bleeds across sessions.
const MSG_QUEUE_CHIP_MAX = 48
// Truncate for the chip label. Kept in sync with MSG_QUEUE_CHIP_MAX; the full
// text lives on the chip's title attribute so hover reveals the whole thing.
function truncateQueueText(text) {
const t = String(text || '')
return t.length > MSG_QUEUE_CHIP_MAX ? t.slice(0, MSG_QUEUE_CHIP_MAX - 1) + '…' : t
}
// Swap a chip's static label for an inline <input> seeded with the full text.
// Enter / blur commits via msgQueue.update (blank ⇒ delete, matching the
// model's "never keep a blank" rule); Escape re-renders to cancel.
function beginQueueChipEdit(sessionId, id, chipEl, fullText) {
if (!chipEl) return
const input = document.createElement('input')
input.type = 'text'
input.className = 'msg-queue-chip-edit'
input.value = fullText
let done = false
const commit = (save) => {
if (done) return
done = true
if (save) {
const v = input.value.trim()
if (!v) msgQueue.remove(sessionId, id)
else msgQueue.update(sessionId, id, v)
}
renderMsgQueueStrip()
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); commit(true) }
else if (e.key === 'Escape') { e.preventDefault(); commit(false) }
})
input.addEventListener('blur', () => commit(true))
chipEl.textContent = ''
chipEl.appendChild(input)
input.focus()
}
function makeQueueChip(sessionId, item) {
const chip = document.createElement('span')
chip.className = 'msg-queue-chip'
chip.dataset.queueId = String(item.id)
const label = document.createElement('button')
label.type = 'button'
label.className = 'msg-queue-chip-text'
label.textContent = truncateQueueText(item.text)
label.title = item.text + '\n(click to edit)'
label.addEventListener('click', () => beginQueueChipEdit(sessionId, item.id, chip, item.text))
// "send next" — promote to head so the next drain sends this item first.
const promoteBtn = document.createElement('button')
promoteBtn.type = 'button'
promoteBtn.className = 'msg-queue-chip-btn msg-queue-chip-promote'
promoteBtn.title = 'Send this one next'
promoteBtn.setAttribute('aria-label', 'Send this message next')
promoteBtn.textContent = '↑'
promoteBtn.addEventListener('click', () => { msgQueue.promote(sessionId, item.id); renderMsgQueueStrip() })
const delBtn = document.createElement('button')
delBtn.type = 'button'
delBtn.className = 'msg-queue-chip-btn msg-queue-chip-del'
delBtn.title = 'Remove from queue'
delBtn.setAttribute('aria-label', 'Remove queued message')
delBtn.textContent = '×'
delBtn.addEventListener('click', () => { msgQueue.remove(sessionId, item.id); renderMsgQueueStrip() })
chip.append(label, promoteBtn, delBtn)
return chip
}
function renderMsgQueueStrip() {
if (!msgQueueStripEl || !msgQueue) return
const sid = state.activeSessionId
const items = sid ? msgQueue.list(sid) : []
msgQueueStripEl.textContent = ''
if (items.length === 0) {
msgQueueStripEl.hidden = true
return
}
msgQueueStripEl.hidden = false
const badge = document.createElement('span')
badge.className = 'msg-queue-count'
badge.textContent = `queued ${items.length}`
msgQueueStripEl.appendChild(badge)
// Only the head is a promote target worth showing an active arrow on, but
// we render the affordance on every non-head chip; the head chip's arrow is
// greyed (already next) via the .is-head class.
for (let i = 0; i < items.length; i++) {
const chip = makeQueueChip(sid, items[i])
if (i === 0) chip.classList.add('is-head')
msgQueueStripEl.appendChild(chip)
}
}
async function send() {
const text = inputEl.value.trim()
if (!text) return
// Mid-turn Enter: the active session already has a prompt in flight (the
// wire accepts only one at a time). Park this text in the session's queue
// and clear the composer as if sent — it auto-drains on turn/end. Only
// applies when there's an active session AND a turn is running; a fresh
// "+" session or an idle session falls through to the normal send path.
if (msgQueue && state.activeSessionId && state.inflightTurn) {
const sid = state.activeSessionId
const meta = state.sessions.get(sid)
if (meta && !meta.hasUserMessage) {
meta.hasUserMessage = true
if (!meta.title) meta.title = text.slice(0, 40)
}
msgQueue.enqueue(sid, text)
inputEl.value = ''
renderMsgQueueStrip()
return
}
if (!state.activeSessionId) {
const { id } = await window.dsh.newSession()
ensureSession(id, { title: text.slice(0, 40), hasUserMessage: true })
@@ -6091,22 +6241,66 @@ async function send() {
}
}
const sid = state.activeSessionId
// Optimistic bubble — the server echoes user/message shortly after, and the
// event handler adopts this element in place instead of re-rendering.
appendMessage({ role: 'user', text, optimistic: true })
updateEmptyStateVisibility()
inputEl.value = ''
sendBtn.disabled = true
state.inflightTurn = true; updateCancelButton(); updateCompactButton(); updateForkButtons()
await dispatchPrompt(sid, text, { clearComposer: true })
}
// The shared "actually send a prompt" tail: optimistic bubble + inflight
// flag + sendPrompt, with the existing error surface. Extracted from send()
// so the queue's auto-drain sends a parked message through the exact same
// path a live Enter takes. `opts.clearComposer` empties the textarea (live
// send); the drain path leaves it alone (the user may be mid-typing the next
// message). The optimistic bubble + inflight flag only apply when `sid` is
// the active session — a drain firing for a background session's turn/end
// must not paint into the foreground stream (the echoed user/message renders
// when that session is next viewed).
async function dispatchPrompt(sid, text, opts = {}) {
const isActive = sid === state.activeSessionId
if (isActive) {
// Optimistic bubble — the server echoes user/message shortly after, and
// the event handler adopts this element in place instead of re-rendering.
appendMessage({ role: 'user', text, optimistic: true })
updateEmptyStateVisibility()
if (opts.clearComposer) inputEl.value = ''
sendBtn.disabled = true
state.inflightTurn = true; updateCancelButton(); updateCompactButton(); updateForkButtons()
}
const meta = state.sessions.get(sid)
if (meta) meta.running = true
try {
await window.dsh.sendPrompt(sid, text)
} catch (err) {
// Surface the error on the stream (matches the live-send path). We do
// NOT clear the rest of the queue — a failed drain leaves the remaining
// items parked so the user can retry / edit rather than losing them.
appendSystem(`error: ${err.message}`)
} finally {
sendBtn.disabled = false
if (isActive) sendBtn.disabled = false
}
}
// Drain exactly one queued message for `sessionId` and send it — guarded so
// it fires at most once per turn even when both turn/end and
// session.finished arrive for the same turn. The `_turnDrainPending` flag is
// armed on turn/start and consumed here. ONE item per turn completion, so
// each queued follow-up waits for its own turn to finish (the head's
// turn/end drains the next head, and so on). Runs after the completion
// handler has flipped inflightTurn off, so dispatchPrompt re-arms it for the
// drained send.
async function drainMsgQueueOnce(sessionId) {
if (!msgQueue) return
const meta = state.sessions.get(sessionId)
// Guard: only the first completion signal for this turn drains. Absent a
// meta (defensive) we still allow one drain.
if (meta) {
if (!meta._turnDrainPending) return
meta._turnDrainPending = false
}
const next = msgQueue.drain(sessionId)
renderMsgQueueStrip()
if (!next) return
await dispatchPrompt(sessionId, next.text, { clearComposer: false })
}
async function cancel() {
if (!state.activeSessionId) return
try {
@@ -6171,6 +6365,11 @@ window.dsh.onNotify(({ method, params }) => {
if (params.sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateForkButtons() }
// Refresh listing so title/lastEventTime catch up.
void refreshSessionList()
// Drain-once: if this session's turn ended via session.finished without a
// clean turn/end (error/cancel paths), the guard fires the single drain
// here. If turn/end already drained, `_turnDrainPending` is false and this
// is a no-op.
void drainMsgQueueOnce(params.sessionId)
} else if (method === 'subagent.started') {
// Grow the tree eagerly: register the child session with a placeholder
// parent link, refresh the sidebar, and drop a fork marker on the parent
@@ -6615,6 +6814,19 @@ window.dsh.onInitialized((info) => {
titleEl.textContent = 'New chat'
updateEmptyStateVisibility()
updateCancelButton()
// Runtime crash / profile switch = a fresh session namespace (the old
// session ids can never come back). Any messages still queued against the
// dead runtime's sessions would send into the void, so wipe every queue
// and — if we actually dropped anything — drop a one-line notice so the
// user knows their parked follow-ups didn't survive the restart. Mirrors
// the interrupt-invalidate posture (stale-against-restart state is a trap).
if (msgQueue) {
const dropped = msgQueue.clearAll()
if (dropped > 0) {
appendSystem(`runtime restarted — cleared ${dropped} queued message${dropped === 1 ? '' : 's'}`)
}
renderMsgQueueStrip()
}
// New runtime = unknown compact support; button falls back to greyed
// until the first successful call proves the daemon accepts the method
// (or a MethodNotFound flips it to `false`).
@@ -8211,6 +8423,14 @@ window.__dshRenderer = {
sessionIds: Array.from(state.sessions.keys()),
replayingId: state.replayingId,
}),
// Message queue (lane-msg-queue) seam: expose the send entry point, the
// pure queue handle, and the strip renderer so renderer-level tests and QA
// scripts can walk the enqueue-on-inflight + auto-drain wiring without a
// real wire round-trip. `getMsgQueue` returns the live model instance.
send,
getMsgQueue: () => msgQueue,
listMsgQueue: (sid) => (msgQueue ? msgQueue.list(sid) : []),
renderMsgQueueStrip,
}
// `⌘.` (Ctrl-. on non-Mac) toggles every trace-event-row
+72
View File
@@ -3673,6 +3673,78 @@ textarea:focus-visible {
cursor: pointer;
}
.composer-model-warn a:hover { color: var(--accent); }
/* Message queue strip (lane-msg-queue). Sits above the composer frame, shown
only when the active session has queued mid-turn messages. One chip per
queued message + a leading "queued N" counter. Chip grammar mirrors the
composer chip pills (999px radius, muted surface, 11.5px label) so it reads
as part of the composer, not a separate widget. */
.msg-queue-strip {
display: flex; align-items: center; flex-wrap: wrap;
gap: 6px;
margin: 0 4px 6px 4px;
padding: 4px 2px;
}
.msg-queue-count {
display: inline-flex; align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 10.5px; font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
flex: 0 0 auto;
}
.msg-queue-chip {
display: inline-flex; align-items: center; gap: 2px;
padding: 2px 4px 2px 8px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
max-width: 280px;
transition: border-color 120ms ease, background 120ms ease;
}
.msg-queue-chip:hover { border-color: var(--accent); }
.msg-queue-chip-text {
border: none; background: transparent;
padding: 0; margin: 0;
color: var(--text-secondary);
font: inherit; font-size: 11.5px; line-height: 1.4;
max-width: 200px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
cursor: text;
}
.msg-queue-chip-text:hover { color: var(--text); }
.msg-queue-chip-edit {
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit; font-size: 11.5px; line-height: 1.4;
padding: 1px 6px;
min-width: 180px;
outline: none;
}
.msg-queue-chip-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px;
padding: 0; border: none;
border-radius: 999px;
background: transparent;
color: var(--text-tertiary);
font-size: 13px; line-height: 1;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.msg-queue-chip-btn:hover { background: var(--surface-hover); color: var(--text); }
.msg-queue-chip-del:hover { color: var(--err); }
/* The head chip is already "next" dim its promote arrow so the affordance
reads as inert without removing it (keeps chip layout stable). */
.msg-queue-chip.is-head .msg-queue-chip-promote {
opacity: 0.35;
pointer-events: none;
}
.composer-chip--mode {
color: var(--text-secondary);
}
@@ -0,0 +1,145 @@
// Unit tests for msg-queue-model.js — the per-session FIFO backing the
// composer's mid-turn message queue. Pure data structure, no DOM.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { createMsgQueue } = require('../src/renderer/msg-queue-model.js')
test('enqueue returns a monotonic id and list preserves FIFO order', () => {
const q = createMsgQueue()
const a = q.enqueue('s1', 'first')
const b = q.enqueue('s1', 'second')
const c = q.enqueue('s1', 'third')
assert.ok(a < b && b < c, 'ids are monotonic')
assert.deepEqual(q.list('s1').map((x) => x.text), ['first', 'second', 'third'])
})
test('enqueue trims text and rejects empty / whitespace-only', () => {
const q = createMsgQueue()
assert.equal(q.enqueue('s1', ''), null)
assert.equal(q.enqueue('s1', ' '), null)
assert.equal(q.enqueue('s1', '\n\t '), null)
const id = q.enqueue('s1', ' hi ')
assert.ok(id)
assert.equal(q.list('s1')[0].text, 'hi', 'stored text is trimmed')
assert.equal(q.size('s1'), 1, 'rejected blanks never entered the queue')
})
test('enqueue with null sessionId is a no-op', () => {
const q = createMsgQueue()
assert.equal(q.enqueue(null, 'x'), null)
assert.equal(q.enqueue(undefined, 'x'), null)
})
test('drain pops the head and returns null when empty/unknown', () => {
const q = createMsgQueue()
assert.equal(q.drain('s1'), null, 'unknown session drains to null')
q.enqueue('s1', 'one')
q.enqueue('s1', 'two')
assert.equal(q.drain('s1').text, 'one', 'head first')
assert.equal(q.drain('s1').text, 'two')
assert.equal(q.drain('s1'), null, 'emptied queue drains to null')
})
test('drain-once semantics: one call removes exactly one item', () => {
const q = createMsgQueue()
q.enqueue('s1', 'a')
q.enqueue('s1', 'b')
q.enqueue('s1', 'c')
const first = q.drain('s1')
assert.equal(first.text, 'a')
assert.equal(q.size('s1'), 2, 'the other two wait for their own turn ends')
assert.deepEqual(q.list('s1').map((x) => x.text), ['b', 'c'])
})
test('remove drops a specific item by id; returns false when absent', () => {
const q = createMsgQueue()
const a = q.enqueue('s1', 'a')
const b = q.enqueue('s1', 'b')
assert.equal(q.remove('s1', a), true)
assert.deepEqual(q.list('s1').map((x) => x.text), ['b'])
assert.equal(q.remove('s1', a), false, 'already gone')
assert.equal(q.remove('nope', b), false, 'unknown session')
})
test('update rewrites text, trims, and rejects blank', () => {
const q = createMsgQueue()
const id = q.enqueue('s1', 'old')
assert.equal(q.update('s1', id, ' new '), true)
assert.equal(q.list('s1')[0].text, 'new')
assert.equal(q.update('s1', id, ' '), false, 'blank edit rejected')
assert.equal(q.list('s1')[0].text, 'new', 'text unchanged after rejected edit')
assert.equal(q.update('s1', 9999, 'x'), false, 'unknown id')
})
test('promote moves an item to the head so the next drain sends it', () => {
const q = createMsgQueue()
q.enqueue('s1', 'a')
const b = q.enqueue('s1', 'b')
q.enqueue('s1', 'c')
assert.equal(q.promote('s1', b), true)
assert.deepEqual(q.list('s1').map((x) => x.text), ['b', 'a', 'c'])
assert.equal(q.drain('s1').text, 'b', 'promoted item drains first')
})
test('promote is idempotent on the head and single-item queues', () => {
const q = createMsgQueue()
const a = q.enqueue('s1', 'a')
assert.equal(q.promote('s1', a), true, 'single item head promote succeeds')
assert.deepEqual(q.list('s1').map((x) => x.text), ['a'])
const b = q.enqueue('s1', 'b')
assert.equal(q.promote('s1', a), true, 'already-head promote succeeds')
assert.deepEqual(q.list('s1').map((x) => x.text), ['a', 'b'])
assert.equal(q.promote('s1', 4242), false, 'unknown id fails')
})
test('per-session isolation: operations never cross sessions', () => {
const q = createMsgQueue()
const a1 = q.enqueue('s1', 's1-a')
q.enqueue('s2', 's2-a')
q.enqueue('s2', 's2-b')
assert.equal(q.size('s1'), 1)
assert.equal(q.size('s2'), 2)
// drain s1 leaves s2 untouched
assert.equal(q.drain('s1').text, 's1-a')
assert.equal(q.size('s2'), 2, 's2 unaffected by s1 drain')
// remove/promote keyed on the wrong session is a no-op
assert.equal(q.remove('s2', a1), false, 's1 id not found in s2')
assert.equal(q.promote('s2', a1), false)
assert.deepEqual(q.list('s2').map((x) => x.text), ['s2-a', 's2-b'])
})
test('clear empties one session and returns the count dropped', () => {
const q = createMsgQueue()
q.enqueue('s1', 'a')
q.enqueue('s1', 'b')
q.enqueue('s2', 'c')
assert.equal(q.clear('s1'), 2)
assert.equal(q.size('s1'), 0)
assert.equal(q.size('s2'), 1, 'other session survives a targeted clear')
assert.equal(q.clear('unknown'), 0)
})
test('clearAll wipes every session (crash / profile switch) and totals the drop', () => {
const q = createMsgQueue()
q.enqueue('s1', 'a')
q.enqueue('s1', 'b')
q.enqueue('s2', 'c')
assert.equal(q.clearAll(), 3)
assert.equal(q.size('s1'), 0)
assert.equal(q.size('s2'), 0)
assert.deepEqual(q.list('s1'), [])
})
test('list returns copies — mutating the result cannot corrupt the queue', () => {
const q = createMsgQueue()
q.enqueue('s1', 'a')
const snap = q.list('s1')
snap[0].text = 'HACKED'
snap.push({ id: 999, text: 'injected' })
assert.equal(q.list('s1')[0].text, 'a', 'internal item untouched')
assert.equal(q.size('s1'), 1, 'internal length untouched')
})
@@ -143,6 +143,12 @@ const NON_IIFE_ALLOWLIST = new Set([
'intervention-timeline.js',
'compact-config-model.js',
'subagent-drilldown.js',
// lane-msg-queue (2026-07-20) composer message queue: dual-exported pure
// FIFO model — module.exports for node --test, window.__dshMsgQueueModel
// for the renderer. Same shape as compact-config-model.js; preloadPure
// require()s it so it must not be IIFE-wrapped. Sole top-level binding is
// `function createMsgQueue`, unique across the shared scope.
'msg-queue-model.js',
])
function listRendererScripts() {
@@ -419,6 +419,7 @@ async function loadRenderer(customStubs = {}, options = {}) {
['tool-cards.js', '__dshToolCards'],
['widgets.js', '__dshWidgets'],
['capabilities.js', '__dshCapabilities'],
['msg-queue-model.js', '__dshMsgQueueModel'],
]
for (const [file, key] of preloadPure) {
const p = path.join(__dirname, '..', 'src', 'renderer', file)
@@ -0,0 +1,196 @@
// Renderer-level tests for the composer message queue (lane-msg-queue).
//
// Covers the wiring the pure msg-queue-model.js tests can't reach:
// - send() enqueues instead of sendPrompt when a turn is in flight
// - the queue strip renders/hides per the active session's queue
// - turn/end auto-drains exactly ONE queued item through sendPrompt
// - session.finished drains once too, and doesn't double-drain with turn/end
// - per-session isolation: a background session's turn/end never touches
// the foreground session's composer
// - runtime restart (onInitialized) clears every queue
//
// The harness loads the whole renderer against a DOM stub and exposes the
// send entry point + queue handle via window.__dshRenderer.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { loadRenderer } = require('./renderer-harness.js')
// Drive an in-flight turn on `sid` and make it active, returning refs.
async function activeInflight(renderer, document, sid = 's1') {
renderer.ensureSession(sid, { title: 'sess', header: {}, hasUserMessage: true })
await renderer.selectSession(sid)
renderer.onSessionEvent(sid, { type: 'turn/start', seq: 1 })
return { input: document.getElementById('input') }
}
function sendCalls(dsh) {
return dsh.__calls.filter((c) => c[0] === 'sendPrompt')
}
test('send() enqueues instead of sendPrompt while a turn is in flight', async () => {
const { renderer, document, dsh } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
const before = sendCalls(dsh).length
input.value = 'queued follow-up'
await renderer.send()
assert.equal(sendCalls(dsh).length, before, 'no sendPrompt fired mid-turn')
assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['queued follow-up'])
assert.equal(input.value, '', 'composer cleared as if sent')
})
test('whitespace-only mid-turn input never enqueues', async () => {
const { renderer, document } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = ' '
await renderer.send()
assert.equal(renderer.listMsgQueue('s1').length, 0)
})
test('queue strip renders one chip per queued message + counter, hides when empty', async () => {
const { renderer, document } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
const strip = document.getElementById('msg-queue-strip')
assert.equal(strip.hidden, true, 'hidden with an empty queue')
input.value = 'first'; await renderer.send()
input.value = 'second'; await renderer.send()
assert.equal(strip.hidden, false, 'shown once queued')
assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 2)
const badge = strip.querySelector('.msg-queue-count')
assert.match(badge.textContent, /queued 2/)
})
test('turn/end auto-drains exactly one queued item through sendPrompt', async () => {
const { renderer, document, dsh } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'one'; await renderer.send()
input.value = 'two'; await renderer.send()
const before = sendCalls(dsh).length
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
// drain is async (dispatchPrompt awaits sendPrompt) — let microtasks settle.
await new Promise((r) => setTimeout(r, 5))
const after = sendCalls(dsh)
assert.equal(after.length - before, 1, 'exactly one drained send')
assert.equal(after[after.length - 1][2], 'one', 'FIFO: head sent first')
assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two'],
'the second item waits for its own turn to finish')
})
test('a second turn/end drains the next item (one per completion)', async () => {
const { renderer, document, dsh } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'one'; await renderer.send()
input.value = 'two'; await renderer.send()
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
await new Promise((r) => setTimeout(r, 5))
// the drained 'one' send flipped inflightTurn back on; simulate its turn.
renderer.onSessionEvent('s1', { type: 'turn/start', seq: 3 })
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 4 })
await new Promise((r) => setTimeout(r, 5))
const sent = sendCalls(dsh).map((c) => c[2])
assert.deepEqual(sent, ['one', 'two'], 'both drained in FIFO order across two turns')
assert.equal(renderer.listMsgQueue('s1').length, 0)
})
test('turn/end + session.finished for one turn drains only once', async () => {
const { renderer, document, dsh, listeners } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'one'; await renderer.send()
input.value = 'two'; await renderer.send()
const before = sendCalls(dsh).length
// Some daemons emit both boundaries for the same turn.
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
await new Promise((r) => setTimeout(r, 5))
listeners.onNotify({ method: 'session.finished', params: { sessionId: 's1', status: 'ok' } })
await new Promise((r) => setTimeout(r, 5))
assert.equal(sendCalls(dsh).length - before, 1, 'drain-once guard held across both signals')
assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two'])
})
test('session.finished alone (no turn/end) still drains once', async () => {
const { renderer, document, dsh, listeners } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'only'; await renderer.send()
const before = sendCalls(dsh).length
listeners.onNotify({ method: 'session.finished', params: { sessionId: 's1', status: 'error' } })
await new Promise((r) => setTimeout(r, 5))
assert.equal(sendCalls(dsh).length - before, 1, 'error-path completion drained the head')
})
test('cancelled turn (turn/end after cancel) still drains — queue survives Cancel', async () => {
const { renderer, document, dsh } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'after cancel'; await renderer.send()
// User cancels; the wire still closes the turn with turn/end.
const before = sendCalls(dsh).length
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
await new Promise((r) => setTimeout(r, 5))
assert.equal(sendCalls(dsh).length - before, 1, 'queued follow-up sent on the cancelled turn end')
})
test('per-session isolation: a background turn/end never touches the active composer', async () => {
const { renderer, document, dsh } = await loadRenderer()
// s1 active + in flight, one queued item.
const { input } = await activeInflight(renderer, document, 's1')
input.value = 's1-queued'; await renderer.send()
// s2 exists, has its own in-flight turn + queue, but is NOT active.
renderer.ensureSession('s2', { title: 's2', header: {}, hasUserMessage: true, running: true })
renderer.getMsgQueue().enqueue('s2', 's2-queued')
// Arm s2's drain flag via a background turn/start (not the active session,
// so it doesn't touch the composer), then end it.
renderer.onSessionEvent('s2', { type: 'turn/start', seq: 8 })
const before = sendCalls(dsh).length
renderer.onSessionEvent('s2', { type: 'turn/end', seq: 9 })
await new Promise((r) => setTimeout(r, 5))
const sent = sendCalls(dsh).slice(before).map((c) => [c[1], c[2]])
assert.deepEqual(sent, [['s2', 's2-queued']], 'only s2 drained, sent against s2')
// s1's queue + composer untouched.
assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['s1-queued'])
const strip = document.getElementById('msg-queue-strip')
assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1, 'active strip still shows s1')
})
test('switching sessions shows the target session\'s queue (strict isolation)', async () => {
const { renderer, document } = await loadRenderer()
const { input } = await activeInflight(renderer, document, 's1')
input.value = 's1-only'; await renderer.send()
const strip = document.getElementById('msg-queue-strip')
assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1)
// Switch to a fresh idle session — its queue is empty, strip hides.
renderer.ensureSession('s2', { title: 's2', header: {}, hasUserMessage: true })
await renderer.selectSession('s2')
assert.equal(strip.hidden, true, 'empty queue on s2 hides the strip')
// Switch back — s1's queue is intact.
await renderer.selectSession('s1')
assert.equal(strip.querySelectorAll('.msg-queue-chip').length, 1)
})
test('runtime restart (onInitialized) clears every queue and posts a notice', async () => {
const { renderer, document, listeners } = await loadRenderer()
const { input } = await activeInflight(renderer, document, 's1')
input.value = 'doomed'; await renderer.send()
renderer.getMsgQueue().enqueue('s2', 'also doomed')
// Fire the initialize handshake (new daemon / profile switch).
listeners.onInitialized({ serverInfo: { name: 'echo', version: '1' }, protocolVersion: 1 })
await new Promise((r) => setTimeout(r, 5))
assert.equal(renderer.getMsgQueue().size('s1'), 0)
assert.equal(renderer.getMsgQueue().size('s2'), 0)
assert.match(renderer.getStreamText(), /cleared 2 queued messages/)
})
test('drained send re-arms inflightTurn so a follow-up Enter queues again', async () => {
const { renderer, document, dsh } = await loadRenderer()
const { input } = await activeInflight(renderer, document)
input.value = 'one'; await renderer.send()
input.value = 'two'; await renderer.send()
renderer.onSessionEvent('s1', { type: 'turn/end', seq: 2 })
await new Promise((r) => setTimeout(r, 5))
// 'one' is now in flight (dispatchPrompt set inflightTurn). A new Enter
// should queue behind 'two', not fire a second concurrent sendPrompt.
const before = sendCalls(dsh).length
input.value = 'three'; await renderer.send()
assert.equal(sendCalls(dsh).length, before, 'follow-up queued, not sent concurrently')
assert.deepEqual(renderer.listMsgQueue('s1').map((x) => x.text), ['two', 'three'])
})