diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..99db366 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git fetch:*)", + "Bash(git merge:*)", + "Bash(npx tsc:*)", + "Bash(npm run build:*)", + "Bash(npm install:*)", + "Bash(./node_modules/.bin/tsc:*)", + "Bash(npm test:*)", + "Bash(cargo check:*)", + "Bash(cargo clippy:*)" + ] + } +} diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index fc0e871..713ec7f 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -350,11 +350,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 3ec0e5f..5f6c7e3 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -134,7 +134,7 @@ describe("structured connection: data flow", () => { } }); input.dispatchEvent(paste); conn.el.querySelector("form")!.dispatchEvent(new Event("submit", { cancelable: true })); - await vi.waitFor(() => expect(input.disabled).toBe(false)); + await vi.waitFor(() => expect(conn.el.textContent).toContain("Upload failed")); expect(input.value).toBe("Read this"); expect(sendStructuredPrompt).not.toHaveBeenCalled(); const remove = conn.el.querySelector('[aria-label="Remove notes.txt"]')!; @@ -693,7 +693,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(); @@ -701,13 +701,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("Send"); 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(); }); @@ -770,7 +770,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] }); @@ -783,12 +783,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(); }); @@ -840,7 +871,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(); }); @@ -1159,7 +1190,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 }); @@ -1211,12 +1242,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(); @@ -1617,7 +1648,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(); @@ -1625,7 +1656,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 8e9af9d..4390e27 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -117,6 +117,7 @@ export function createStructuredConnection( + @@ -136,11 +137,14 @@ 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; const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement; const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement; const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement; type Attachment = { file: File; path?: string }; + type QueuedPrompt = { text: string; attachments: Attachment[] }; let attachments: Attachment[] = []; let uploading = false; let pasteCount = 0; @@ -158,7 +162,7 @@ export function createStructuredConnection( remove.type = "button"; remove.textContent = "×"; remove.setAttribute("aria-label", `Remove ${attachment.file.name}`); - remove.disabled = input.disabled; + remove.disabled = uploading; remove.addEventListener("click", () => { attachments = attachments.filter((item) => item !== attachment); renderAttachments(); @@ -169,7 +173,7 @@ export function createStructuredConnection( } function addFiles(files: File[]): void { - if (input.disabled || disposed) return; + if (uploading || disposed) return; for (const file of files) { if (file.size > 20 * 1024 * 1024) { showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`); @@ -196,7 +200,7 @@ export function createStructuredConnection( }); input.addEventListener("paste", (event) => { const clipboard = event.clipboardData; - if (!clipboard || input.disabled) return; + if (!clipboard || uploading) return; if (clipboard.files.length) { event.preventDefault(); addFiles(Array.from(clipboard.files)); @@ -247,6 +251,8 @@ export function createStructuredConnection( let disposed = false; let optimisticTurnStartedAt: number | null = null; let activityTimer: number | null = null; + const promptQueue: QueuedPrompt[] = []; + let promptRequestPending = false; // Within 64px, wheel and touch imprecision should still count as following. const FOLLOW_BOTTOM_THRESHOLD_PX = 64; @@ -647,16 +653,19 @@ export function createStructuredConnection( } function renderComposerState(): void { - const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; const projectedActive = state.turn.responding || state.turn.thinking; - input.disabled = active; - attachBtn.disabled = active; - fileInput.disabled = active; + input.disabled = false; + attachBtn.disabled = uploading; + fileInput.disabled = uploading; renderAttachments(); - sendBtn.disabled = active; - sendBtn.hidden = projectedActive; + sendBtn.disabled = false; + sendBtn.hidden = false; + sendLabel.textContent = "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(); } @@ -718,6 +727,7 @@ export function createStructuredConnection( } renderConfig(); renderComposerState(); + dispatchQueuedPrompt(); } function closeConfigMenu(): void { @@ -917,17 +927,14 @@ export function createStructuredConnection( if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); }, 0); }); - composerForm.addEventListener("submit", (e) => { - e.preventDefault(); - const text = input.value; - if ((!text.trim() && attachments.length === 0) || input.disabled) return; - const submittedAttachments = attachments; + function dispatchPrompt(text: string, submittedAttachments: Attachment[] = []): void { uploading = submittedAttachments.length > 0; const generation = ++submissionGeneration; const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; optimisticTurnStartedAt = Date.now(); acceptedSubmission = null; + promptRequestPending = true; input.value = ""; autoResize(); clearError(); @@ -944,6 +951,7 @@ export function createStructuredConnection( }; void send().then( () => { + promptRequestPending = false; if (disposed || generation !== submissionGeneration) return; uploading = false; if (attachments === submittedAttachments) { @@ -957,11 +965,17 @@ 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; uploading = false; if (disposed || generation !== submissionGeneration) return; if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null; + if (attachments.length === 0 && submittedAttachments.length > 0) { + attachments = submittedAttachments; + } if (input.value === "") { input.value = text; autoResize(); @@ -970,6 +984,32 @@ 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; + const next = promptQueue.shift()!; + dispatchPrompt(next.text, next.attachments); + } + + composerForm.addEventListener("submit", (e) => { + e.preventDefault(); + const text = input.value; + if ((!text.trim() && attachments.length === 0) || uploading) return; + const submittedAttachments = attachments; + const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; + if (active || promptQueue.length > 0) { + promptQueue.push({ text, attachments: submittedAttachments }); + attachments = []; + input.value = ""; + autoResize(); + clearError(); + renderComposerState(); + dispatchQueuedPrompt(); + return; + } + dispatchPrompt(text, submittedAttachments); }); cancelBtn.addEventListener("click", () => { clearError(); diff --git a/web/src/styles.css b/web/src/styles.css index e45eb60..843451a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3093,6 +3093,7 @@ input.leaf-rename:focus { hairline that doubles as the focus indicator. Inner metrics line the draft up with the transcript's own 12px gutter. */ .structured-composer { + position: relative; flex: 0 0 auto; padding: var(--sp-1) var(--sp-1) max(var(--sp-1), env(safe-area-inset-bottom)); border-top: 1px solid var(--border); @@ -3103,6 +3104,19 @@ input.leaf-rename:focus { transition: border-color 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-top-color: var(--border); }