feat(desktop): per-session mid-turn message queue (enqueue-on-inflight, drain-once-per-turn, strip UI)
This commit is contained in:
@@ -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
|
and why. A `Cancel` button appears mid-turn and cuts the stream via
|
||||||
`session/cancel`.
|
`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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Session Tree
|
#### Session Tree
|
||||||
|
|||||||
BIN
examples/desktop/docs/qa-msg-queue/01-inflight-two-queued.png
Normal file
BIN
examples/desktop/docs/qa-msg-queue/01-inflight-two-queued.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
243
examples/desktop/scripts/qa-cdp-shoot-msg-queue.mjs
Normal file
243
examples/desktop/scripts/qa-cdp-shoot-msg-queue.mjs
Normal file
@@ -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) })
|
||||||
@@ -517,6 +517,13 @@
|
|||||||
matching the widget action catalog (see next-actions.js).
|
matching the widget action catalog (see next-actions.js).
|
||||||
Empty by default; hidden entirely when no suggestions land. -->
|
Empty by default; hidden entirely when no suggestions land. -->
|
||||||
<div id="next-action-chips" class="next-action-chips" hidden></div>
|
<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">
|
<div class="composer-frame">
|
||||||
<textarea id="input" rows="3" placeholder="Message DSH… (Enter to send, Shift+Enter for newline)"></textarea>
|
<textarea id="input" rows="3" placeholder="Message DSH… (Enter to send, Shift+Enter for newline)"></textarea>
|
||||||
<div class="composer-bar">
|
<div class="composer-bar">
|
||||||
@@ -1487,6 +1494,7 @@
|
|||||||
drill-down view models. Loaded before subagent-view.js so
|
drill-down view models. Loaded before subagent-view.js so
|
||||||
buildInlineSubagentTrace can pick up the drill-down helpers. -->
|
buildInlineSubagentTrace can pick up the drill-down helpers. -->
|
||||||
<script src="./compact-config-model.js"></script>
|
<script src="./compact-config-model.js"></script>
|
||||||
|
<script src="./msg-queue-model.js"></script>
|
||||||
<script src="./subagent-drilldown.js"></script>
|
<script src="./subagent-drilldown.js"></script>
|
||||||
<script src="./context-rail.js"></script>
|
<script src="./context-rail.js"></script>
|
||||||
<script src="./workflow-view.js"></script>
|
<script src="./workflow-view.js"></script>
|
||||||
|
|||||||
142
examples/desktop/src/renderer/msg-queue-model.js
Normal file
142
examples/desktop/src/renderer/msg-queue-model.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
@@ -99,6 +99,19 @@ const statusText = document.getElementById('status-text')
|
|||||||
const modelBadge = document.getElementById('model-badge')
|
const modelBadge = document.getElementById('model-badge')
|
||||||
const profileSelect = document.getElementById('profile')
|
const profileSelect = document.getElementById('profile')
|
||||||
const newSessionBtn = document.getElementById('new-session')
|
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 = {
|
const state = {
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
@@ -586,6 +599,9 @@ async function selectSession(id) {
|
|||||||
// their seed boundary. During live streaming we grow the same map when
|
// their seed boundary. During live streaming we grow the same map when
|
||||||
// subagent.started fires.
|
// subagent.started fires.
|
||||||
installKnownForkMarkers(id)
|
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) {
|
async function replayHistory(id) {
|
||||||
@@ -4856,6 +4872,11 @@ function onSessionEvent(sessionId, event) {
|
|||||||
// without a badge instead of one from the previous turn.
|
// without a badge instead of one from the previous turn.
|
||||||
const startData = event.data || event
|
const startData = event.data || event
|
||||||
meta.currentTurnTrigger = (startData && startData.trigger) || null
|
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() }
|
if (sessionId === state.activeSessionId) { state.inflightTurn = true; updateCancelButton(); updateCompactButton(); updateForkButtons() }
|
||||||
renderSessionList()
|
renderSessionList()
|
||||||
}
|
}
|
||||||
@@ -4864,6 +4885,11 @@ function onSessionEvent(sessionId, event) {
|
|||||||
meta.currentTurnTrigger = null
|
meta.currentTurnTrigger = null
|
||||||
if (sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateCompactButton(); updateForkButtons() }
|
if (sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateCompactButton(); updateForkButtons() }
|
||||||
renderSessionList()
|
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
|
if (event.time && event.time > meta.lastEventTime) meta.lastEventTime = event.time
|
||||||
// §1.1 trace bucket: non-boundary events get bucketed into
|
// §1.1 trace bucket: non-boundary events get bucketed into
|
||||||
@@ -6071,9 +6097,133 @@ function onInterruptInvalidate({ interruptId, reason }) {
|
|||||||
|
|
||||||
// -- send / cancel -----------------------------------------------------------
|
// -- 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() {
|
async function send() {
|
||||||
const text = inputEl.value.trim()
|
const text = inputEl.value.trim()
|
||||||
if (!text) return
|
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) {
|
if (!state.activeSessionId) {
|
||||||
const { id } = await window.dsh.newSession()
|
const { id } = await window.dsh.newSession()
|
||||||
ensureSession(id, { title: text.slice(0, 40), hasUserMessage: true })
|
ensureSession(id, { title: text.slice(0, 40), hasUserMessage: true })
|
||||||
@@ -6091,22 +6241,66 @@ async function send() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const sid = state.activeSessionId
|
const sid = state.activeSessionId
|
||||||
// Optimistic bubble — the server echoes user/message shortly after, and the
|
await dispatchPrompt(sid, text, { clearComposer: true })
|
||||||
// event handler adopts this element in place instead of re-rendering.
|
}
|
||||||
appendMessage({ role: 'user', text, optimistic: true })
|
|
||||||
updateEmptyStateVisibility()
|
// The shared "actually send a prompt" tail: optimistic bubble + inflight
|
||||||
inputEl.value = ''
|
// flag + sendPrompt, with the existing error surface. Extracted from send()
|
||||||
sendBtn.disabled = true
|
// so the queue's auto-drain sends a parked message through the exact same
|
||||||
state.inflightTurn = true; updateCancelButton(); updateCompactButton(); updateForkButtons()
|
// 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 {
|
try {
|
||||||
await window.dsh.sendPrompt(sid, text)
|
await window.dsh.sendPrompt(sid, text)
|
||||||
} catch (err) {
|
} 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}`)
|
appendSystem(`error: ${err.message}`)
|
||||||
} finally {
|
} 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() {
|
async function cancel() {
|
||||||
if (!state.activeSessionId) return
|
if (!state.activeSessionId) return
|
||||||
try {
|
try {
|
||||||
@@ -6171,6 +6365,11 @@ window.dsh.onNotify(({ method, params }) => {
|
|||||||
if (params.sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateForkButtons() }
|
if (params.sessionId === state.activeSessionId) { state.inflightTurn = false; updateCancelButton(); updateForkButtons() }
|
||||||
// Refresh listing so title/lastEventTime catch up.
|
// Refresh listing so title/lastEventTime catch up.
|
||||||
void refreshSessionList()
|
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') {
|
} else if (method === 'subagent.started') {
|
||||||
// Grow the tree eagerly: register the child session with a placeholder
|
// Grow the tree eagerly: register the child session with a placeholder
|
||||||
// parent link, refresh the sidebar, and drop a fork marker on the parent
|
// 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'
|
titleEl.textContent = 'New chat'
|
||||||
updateEmptyStateVisibility()
|
updateEmptyStateVisibility()
|
||||||
updateCancelButton()
|
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
|
// New runtime = unknown compact support; button falls back to greyed
|
||||||
// until the first successful call proves the daemon accepts the method
|
// until the first successful call proves the daemon accepts the method
|
||||||
// (or a MethodNotFound flips it to `false`).
|
// (or a MethodNotFound flips it to `false`).
|
||||||
@@ -8211,6 +8423,14 @@ window.__dshRenderer = {
|
|||||||
sessionIds: Array.from(state.sessions.keys()),
|
sessionIds: Array.from(state.sessions.keys()),
|
||||||
replayingId: state.replayingId,
|
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
|
// `⌘.` (Ctrl-. on non-Mac) toggles every trace-event-row
|
||||||
|
|||||||
@@ -3673,6 +3673,78 @@ textarea:focus-visible {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.composer-model-warn a:hover { color: var(--accent); }
|
.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 {
|
.composer-chip--mode {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
145
examples/desktop/test/msg-queue-model.test.js
Normal file
145
examples/desktop/test/msg-queue-model.test.js
Normal file
@@ -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',
|
'intervention-timeline.js',
|
||||||
'compact-config-model.js',
|
'compact-config-model.js',
|
||||||
'subagent-drilldown.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() {
|
function listRendererScripts() {
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ async function loadRenderer(customStubs = {}, options = {}) {
|
|||||||
['tool-cards.js', '__dshToolCards'],
|
['tool-cards.js', '__dshToolCards'],
|
||||||
['widgets.js', '__dshWidgets'],
|
['widgets.js', '__dshWidgets'],
|
||||||
['capabilities.js', '__dshCapabilities'],
|
['capabilities.js', '__dshCapabilities'],
|
||||||
|
['msg-queue-model.js', '__dshMsgQueueModel'],
|
||||||
]
|
]
|
||||||
for (const [file, key] of preloadPure) {
|
for (const [file, key] of preloadPure) {
|
||||||
const p = path.join(__dirname, '..', 'src', 'renderer', file)
|
const p = path.join(__dirname, '..', 'src', 'renderer', file)
|
||||||
|
|||||||
196
examples/desktop/test/renderer-msg-queue.test.js
Normal file
196
examples/desktop/test/renderer-msg-queue.test.js
Normal 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'])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user