From 43bfda5a60e6250d242269a40bab264aa4af66d0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 7 Sep 2026 10:42:49 +0100 Subject: [PATCH 1/2] fix(web): queue structured prompts --- docs/STRUCTURED-VIEW.md | 8 ++++-- web/src/structured.test.ts | 57 +++++++++++++++++++++++++++++--------- web/src/structured.ts | 51 ++++++++++++++++++++++++++++------ web/src/styles.css | 14 ++++++++++ 4 files changed, 107 insertions(+), 23 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 9eb9a2a..fa51a91 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -310,11 +310,15 @@ thing a callback can be attributed to, so at most one turn may ever be in flight, and a cancelled turn must fully settle before the next prompt is allowed to claim the daemon's idea of "the active turn." -- A prompt is appended as a durable `User` row before it claims the turn and +- Web clients may collect a visible FIFO of operator messages while a turn is + active. They submit each queued message only after the projected turn becomes + idle; the queue is client-local and unsent messages do not survive a reload. +- A submitted prompt is appended as a durable `User` row before it claims the turn and dispatches to ACP, so the transcript never depends on an adapter echoing the operator's input. If that append fails, the prompt is not dispatched. While a turn is active, a second prompt gets `409 turn_in_flight` rather than - queuing — one active turn per session, never a hidden queue of prompts. + queuing in the daemon — one active turn per session, never a hidden + server-side queue of prompts. - Cancel sends `session/cancel`, clears any pending approval as `Cancelled`, and ends the turn's client-visible lifecycle immediately. If the cancelled turn was still *dispatched* to ACP (waiting on its response), the daemon diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 5b86f04..d91e056 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -611,7 +611,7 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); - it("disables the composer while a turn is active and re-enables when idle", () => { + it("keeps the composer available while a turn is active", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); @@ -619,13 +619,13 @@ describe("structured connection: turn/composer gating", () => { const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement; ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); - expect(input.disabled).toBe(true); + expect(input.disabled).toBe(false); expect(cancel.hidden).toBe(false); - expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(true); + expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Queue"); ws.emit({ type: "reduced_state", state: idleState }); expect(input.disabled).toBe(false); expect(cancel.hidden).toBe(true); - expect((conn.el.querySelector(".structured-send") as HTMLButtonElement).hidden).toBe(false); + expect((conn.el.querySelector(".structured-send span") as HTMLElement).textContent).toBe("Send"); conn.dispose(); }); @@ -688,7 +688,7 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); - it("disables immediately and dispatches only once before begin-turn is projected", () => { + it("queues a second message before begin-turn is projected", () => { vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {})); const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); @@ -701,12 +701,43 @@ describe("structured connection: turn/composer gating", () => { input.value = "only once"; form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); + input.value = "queued next"; form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true })); - expect(input.disabled).toBe(true); - expect(send.disabled).toBe(true); + expect(input.disabled).toBe(false); + expect(send.disabled).toBe(false); expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once"); + expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("1 message queued"); + conn.dispose(); + }); + + it("dispatches queued messages in order as turns become idle", async () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; + const submit = (text: string) => { + input.value = text; + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + }; + + submit("first"); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + submit("second"); + submit("third"); + expect(sendStructuredPrompt).toHaveBeenCalledTimes(1); + expect(conn.el.querySelector(".structured-queue")?.textContent).toBe("2 messages queued"); + + await Promise.resolve().then(() => Promise.resolve()); + ws.emit({ type: "reduced_state", state: idleState }); + expect(sendStructuredPrompt).toHaveBeenNthCalledWith(2, "s1", "second"); + ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); + await Promise.resolve().then(() => Promise.resolve()); + ws.emit({ type: "reduced_state", state: idleState }); + expect(sendStructuredPrompt).toHaveBeenNthCalledWith(3, "s1", "third"); + expect((conn.el.querySelector(".structured-queue") as HTMLElement).hidden).toBe(true); conn.dispose(); }); @@ -758,7 +789,7 @@ describe("structured connection: turn/composer gating", () => { expect(sendStructuredPrompt).toHaveBeenCalledTimes(2); expect(input.value).toBe(""); - expect(input.disabled).toBe(true); + expect(input.disabled).toBe(false); expect(conn.el.textContent).not.toContain("stale failure"); conn.dispose(); }); @@ -1077,7 +1108,7 @@ describe("structured connection: reconnection/error/loading", () => { second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "reduced_state", state: idleState }); - expect(input.disabled).toBe(true); + expect(input.disabled).toBe(false); second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); second.emit({ type: "reduced_state", state: idleState }); @@ -1129,12 +1160,12 @@ describe("structured connection: reconnection/error/loading", () => { second.open(); second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); second.emit({ type: "reduced_state", state: idleState }); - expect(input.disabled).toBe(true); + expect(input.disabled).toBe(false); accept(); await Promise.resolve().then(() => Promise.resolve()); - expect(input.disabled).toBe(true); + expect(input.disabled).toBe(false); expect(second.closed).toBe(true); third.open(); @@ -1535,7 +1566,7 @@ describe("structured connection: pane lifecycle", () => { conn.dispose(); }); - it("focusTerminal focuses the composer only when it is enabled", () => { + it("focusTerminal keeps the composer reachable during an active turn", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); ws.open(); @@ -1543,7 +1574,7 @@ describe("structured connection: pane lifecycle", () => { ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } }); conn.focusTerminal(); const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement; - expect(document.activeElement).not.toBe(input); + expect(document.activeElement).toBe(input); ws.emit({ type: "reduced_state", state: idleState }); conn.focusTerminal(); expect(document.activeElement).toBe(input); diff --git a/web/src/structured.ts b/web/src/structured.ts index 3064c21..e38d9bd 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -108,6 +108,7 @@ export function createStructuredConnection( + @@ -127,7 +128,9 @@ export function createStructuredConnection( const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; + const sendLabel = sendBtn.querySelector("span") as HTMLSpanElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; + const queueEl = el.querySelector(".structured-queue") as HTMLSpanElement; disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId)); // Mobile-only (CSS hides it on desktop, where the sidebar is always in @@ -165,6 +168,8 @@ export function createStructuredConnection( let disposed = false; let optimisticTurnStartedAt: number | null = null; let activityTimer: number | null = null; + const promptQueue: string[] = []; + let promptRequestPending = false; // Within 64px, wheel and touch imprecision should still count as following. const FOLLOW_BOTTOM_THRESHOLD_PX = 64; @@ -529,11 +534,15 @@ export function createStructuredConnection( function renderComposerState(): void { const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const projectedActive = state.turn.responding || state.turn.thinking; - input.disabled = active; - sendBtn.disabled = active; - sendBtn.hidden = projectedActive; + input.disabled = false; + sendBtn.disabled = false; + sendBtn.hidden = false; + sendLabel.textContent = active ? "Queue" : "Send"; cancelBtn.hidden = !projectedActive; - if (active) closeCommandMenu(); + queueEl.hidden = promptQueue.length === 0; + queueEl.textContent = promptQueue.length === 1 + ? "1 message queued" + : `${promptQueue.length} messages queued`; renderActivity(); } @@ -595,6 +604,7 @@ export function createStructuredConnection( } renderConfig(); renderComposerState(); + dispatchQueuedPrompt(); } function closeConfigMenu(): void { @@ -794,21 +804,20 @@ export function createStructuredConnection( if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); }, 0); }); - composerForm.addEventListener("submit", (e) => { - e.preventDefault(); - const text = input.value; - if (!text.trim() || input.disabled) return; + function dispatchPrompt(text: string): void { const generation = ++submissionGeneration; const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; optimisticTurnStartedAt = Date.now(); acceptedSubmission = null; + promptRequestPending = true; input.value = ""; autoResize(); clearError(); renderComposerState(); sendStructuredPrompt(sessionId, text).then( () => { + promptRequestPending = false; if ( disposed || generation !== submissionGeneration @@ -819,8 +828,11 @@ export function createStructuredConnection( // A replacement socket may have captured idle before the POST claimed // the prompt. Reconnect again so its snapshot cannot predate the 204. if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation(); + renderComposerState(); + dispatchQueuedPrompt(); }, (e: unknown) => { + promptRequestPending = false; if (disposed || generation !== submissionGeneration) return; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; if (input.value === "") { @@ -831,6 +843,29 @@ export function createStructuredConnection( renderComposerState(); }, ); + } + + function dispatchQueuedPrompt(): void { + const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; + if (disposed || active || promptRequestPending || promptQueue.length === 0) return; + dispatchPrompt(promptQueue.shift()!); + } + + composerForm.addEventListener("submit", (e) => { + e.preventDefault(); + const text = input.value; + if (!text.trim()) return; + const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; + if (active || promptQueue.length > 0) { + promptQueue.push(text); + input.value = ""; + autoResize(); + clearError(); + renderComposerState(); + dispatchQueuedPrompt(); + return; + } + dispatchPrompt(text); }); cancelBtn.addEventListener("click", () => { clearError(); diff --git a/web/src/styles.css b/web/src/styles.css index 8eb6be4..be633fc 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3064,6 +3064,7 @@ input.leaf-rename:focus { /* The composer is a sibling of the scrolling transcript, not part of it: it stays reachable at the pane edge without changing transcript scroll math. */ .structured-composer { + position: relative; flex: 0 0 auto; margin: var(--sp-2) var(--sp-3) max(var(--sp-2), env(safe-area-inset-bottom)); padding: var(--sp-1); @@ -3081,6 +3082,19 @@ input.leaf-rename:focus { box-shadow var(--dur-fast) var(--ease-out); } +.structured-queue { + position: absolute; + right: var(--sp-2); + bottom: calc(100% + var(--sp-1)); + padding: 3px var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-pill); + background: var(--surface); + color: var(--muted); + font-size: var(--fs-11); + box-shadow: var(--shadow-1); +} + .structured-composer:focus-within { border-color: var(--accent); box-shadow: From ba0c54759bcba8ddcee5a291b0b2834e3d760b07 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 7 Sep 2026 11:39:51 +0100 Subject: [PATCH 2/2] fix(tui): complete test presets --- crates/maestro-tui/src/draft.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index ead7fe0..ecc967c 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -2130,11 +2130,13 @@ mod tests { id: "claude".into(), label: "Claude".into(), command: "claude".into(), + auto_approve: false, }, SpawnPreset { id: "codex".into(), label: "Codex".into(), command: "codex".into(), + auto_approve: false, }, ]); assert!(draft.structured_view);