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 files changed
+1052 -8

No files matched your search

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