diff --git a/src/client/app.css b/src/client/app.css index 8f0e280..c900820 100644 --- a/src/client/app.css +++ b/src/client/app.css @@ -607,6 +607,8 @@ body.selecting #selectBtn { .chatrow[data-state="blocked"] .chaticon { color: var(--warn); } +/* 1.6s here is PULSE_MS in convrow.js: rebuilt rows join the pulse mid-stride + by way of a negative animation-delay, which needs the two to agree. */ @keyframes convpulse { 50% { opacity: 0.35; diff --git a/src/client/app.js b/src/client/app.js index 87cdeca..ffa5139 100644 --- a/src/client/app.js +++ b/src/client/app.js @@ -3069,6 +3069,14 @@ import { mountSidebar } from "./sidebar.js"; * back. */ function applyRunState(state) { + // The first frame of a stream reports on HISTORY: it is what says whether + // the message-start we just replayed belongs to a run that is still going. + // Later frames report on the present, where the log's own events (message-end, + // cancelled, run-error) already own how a turn ends — so those only move the + // composer. A live frame must never retitle a turn: it would take one + // mistimed sample to write "interrupted" under a reply still being written. + var fromHistory = !liveState; + liveState = true; if (state === "running") { if (!streaming) { streaming = true; @@ -3079,7 +3087,8 @@ import { mountSidebar } from "./sidebar.js"; if (!streaming) return; streaming = false; var rec = lastAssistant(); - if (rec && rec.turn.classList.contains("generating")) endAssistant(rec, "interrupted"); + if (fromHistory && rec && rec.turn.classList.contains("generating")) + endAssistant(rec, "interrupted"); updateSend(); } function lastAssistant() { @@ -3193,6 +3202,9 @@ import { mountSidebar } from "./sidebar.js"; var retryDelay = RETRY_MIN; var retryCount = 0; var lastSeenId = null; // newest event id received on the current stream + // Whether this stream has already delivered a state frame. The first one is a + // verdict on the history just replayed; the rest are live. See applyRunState. + var liveState = false; var SSE_EVENTS = [ "user-message", "queued-message", @@ -3402,6 +3414,7 @@ import { mountSidebar } from "./sidebar.js"; return out; } function connectStream(id, afterId) { + liveState = false; // a fresh connection reports on the log again before the present var url = "/api/conversations/" + encodeURIComponent(id) + "/stream"; if (afterId) url += "?after=" + encodeURIComponent(afterId); // only the tail after the batch lastSeenId = afterId || null; diff --git a/src/client/convrow.js b/src/client/convrow.js index 27e686f..41373e8 100644 --- a/src/client/convrow.js +++ b/src/client/convrow.js @@ -30,6 +30,22 @@ export function fmtConvDate(ms) { return d.toLocaleDateString(undefined, o); } +/* + * One clock for every state pulse on the page. + * + * The animation lives on elements the lists rebuild — a refresh would otherwise + * restart it, so a conversation that never stopped generating appears to + * stutter every few seconds and after every tab change. A negative delay + * measured from a single epoch drops each new element in mid-stride instead, + * in step with every other one. Keep PULSE_MS equal to the convpulse duration + * in app.css. + */ +var PULSE_MS = 1600; +var PULSE_EPOCH = Date.now(); +export function pulseDelay() { + return "-" + ((Date.now() - PULSE_EPOCH) % PULSE_MS) / 1000 + "s"; +} + /* * Paint a row's live state: generating pulses in the accent colour, waiting on * an answer goes warn-coloured AND swaps to the question icon, so the two never @@ -37,15 +53,18 @@ export function fmtConvDate(ms) { * its rows (which would drop the select-mode checkboxes). */ export function setRowState(row, state) { - var live = state === "running" || state === "blocked"; - if (live) row.dataset.state = state; - else delete row.dataset.state; + state = state === "running" || state === "blocked" ? state : "idle"; + // Re-setting the same state would re-seed the animation, which is the stutter + // this whole clock exists to avoid — and a poll calls this on every row. + if ((row.dataset.state || "idle") === state) return; + if (state === "idle") delete row.dataset.state; + else row.dataset.state = state; var icon = row.querySelector(".chaticon"); - if (icon) { - icon.innerHTML = state === "blocked" ? ASK_ICON : CONV_ICON; - if (live) icon.title = state === "blocked" ? "Waiting for you" : "Generating…"; - else icon.removeAttribute("title"); - } + if (!icon) return; + icon.innerHTML = state === "blocked" ? ASK_ICON : CONV_ICON; + icon.style.animationDelay = state === "running" ? pulseDelay() : ""; + if (state === "idle") icon.removeAttribute("title"); + else icon.title = state === "blocked" ? "Waiting for you" : "Generating…"; } export function convRow(c, opts) { @@ -69,6 +88,7 @@ export function convRow(c, opts) { var icon = document.createElement("span"); icon.className = "chaticon"; + icon.innerHTML = CONV_ICON; // the resting icon; setRowState swaps it if the row is live var main = document.createElement("div"); main.className = "chatmain"; @@ -102,7 +122,7 @@ export function convRow(c, opts) { row.appendChild(icon); row.appendChild(main); row.appendChild(end); - setRowState(row, c.state); // fills the icon, so it runs once the row is built + setRowState(row, c.state); // reaches into the icon, so it runs once the row is built function openMenu(x, y, align, trigger) { openChatMenu(x, y, { diff --git a/src/client/sidebar.js b/src/client/sidebar.js index 0ea93cc..2ce4ed4 100644 --- a/src/client/sidebar.js +++ b/src/client/sidebar.js @@ -9,6 +9,7 @@ * opens the Rename/Delete menu. */ import { openChatMenu } from "./chatmenu.js"; +import { pulseDelay } from "./convrow.js"; import { ASK_ICON, CONV_ICON } from "./icons.js"; import { installSpeculation } from "./prefetch.js"; @@ -115,13 +116,28 @@ export function mountSidebar(config) { } } + // Everything a paint would put on screen, as one string. The list is refetched + // on a timer and repainted on every section change, and rebuilding the rows + // restarts their pulse — so when the answer would be identical, don't. + function paintKey(conversations, count, active) { + var key = String(active) + "|" + count; + for (var i = 0; i < count && i < conversations.length; i++) { + var c = conversations[i]; + key += "|" + c.id + "\u0001" + (c.title || "") + "\u0001" + (c.state || "idle"); + } + return key; + } + var painted = null; function paint(conversations, count) { - railList.innerHTML = ""; // Both highlights (the active recent and "New chat") belong to the chat view. // onChat() lets the shell say whether it's showing; a satellite view like // Settings must not leave a conversation lit. var onChat = config.onChat ? config.onChat() : config.active !== "conversations"; var active = onChat && config.activeId ? config.activeId() : null; + var key = paintKey(conversations, count, active); + if (key === painted) return; + painted = key; + railList.innerHTML = ""; if (!conversations.length) { var e = document.createElement("div"); e.className = "empty"; @@ -138,6 +154,8 @@ export function mountSidebar(config) { if (c.state === "running" || c.state === "blocked") { b.dataset.state = c.state; b.title = c.state === "blocked" ? "Waiting for you" : "Generating…"; + // This row is new, but the pulse it joins is not — see pulseDelay. + if (c.state === "running") b.querySelector("svg").style.animationDelay = pulseDelay(); } var name = document.createElement("span"); name.className = "convname"; diff --git a/src/http.ts b/src/http.ts index 1193bbe..abcdd02 100644 --- a/src/http.ts +++ b/src/http.ts @@ -425,6 +425,12 @@ function openStream(conversationId: string, req: Request, store: Store): Respons // state on connect, then whenever it changes. let state = store.conversationState(conversationId); sub.note("state", { state }); + // "Idle" has to be corroborated before it goes out. One exchange can be two + // jobs (a run, then the flush of what was steered into it), and the gap + // between them is a real moment with no job row — reported straight away it + // reads as "finished", and the composer flips to Send and back a second later. + // Starting is not ambiguous in the same way, so it goes out at once. + let quiet = 0; // A standalone worker persists through its own actor, so it cannot directly // notify this server process's subscribers. The log is authoritative: poll @@ -442,7 +448,8 @@ function openStream(conversationId: string, req: Request, store: Store): Respons // stream — once a second is plenty, and cheap. if (++tick % 4 === 0) { const now = store.conversationState(conversationId); - if (now !== state) { + quiet = now === "idle" ? quiet + 1 : 0; + if (now !== state && (now !== "idle" || quiet >= 2)) { state = now; sub.note("state", { state }); } diff --git a/src/store.ts b/src/store.ts index 7eb988c..d1fd9d9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -197,21 +197,27 @@ const TITLE_OPENER_CHARS = 1_200; const TITLE_REPLY_CHARS = 800; /** - * The three derived columns behind `ConversationState`, written against `ref` — + * The two derived columns behind `ConversationState`, written against `ref` — * `c.id` inside the list queries, a bind parameter when asking about one - * conversation. A job row that hasn't finished means a run is in flight; an - * `ask_user` call with nothing said after it means the run is waiting on a - * person. + * conversation. A job row that hasn't finished means a run is in flight. + * + * `last_gate` decides "waiting on a person": walking the log BACKWARDS, the + * first event that either asks something or settles it. An `ask_user` call is + * the question; anything the user says (or a cancel) answers it. Backwards and + * bounded rather than two MAX() scans over the whole conversation — on a log + * with megabytes of tool traffic the difference is the last turn's worth of + * rows instead of all of them, and `json_extract` runs only on the tool calls + * it walks past on the way. */ function stateColumns(ref: string): string { return `EXISTS (SELECT 1 FROM jobs j WHERE j.conversation_id = ${ref} AND j.status IN ('queued', 'running')) AS active, - (SELECT MAX(e.seq) FROM events e - WHERE e.conversation_id = ${ref} AND e.event = 'tool-call' - AND json_extract(e.data, '$.toolName') = 'ask_user') AS ask_seq, - (SELECT MAX(e.seq) FROM events e + (SELECT e.event FROM events e WHERE e.conversation_id = ${ref} - AND e.event IN ('user-message', 'cancelled')) AS settle_seq`; + AND (e.event IN ('user-message', 'cancelled') + OR (e.event = 'tool-call' + AND json_extract(e.data, '$.toolName') = 'ask_user')) + ORDER BY e.seq DESC LIMIT 1) AS last_gate`; } // The conversation-list SELECT (owner + optional project filters are appended @@ -241,18 +247,15 @@ interface ConversationRow { project_id?: string | null; project_name?: string | null; active: number; - ask_seq: number | null; - settle_seq: number | null; + last_gate: string | null; } -/** Reads the three derived columns as one state. */ -function rowToState(r: { - active: number; - ask_seq: number | null; - settle_seq: number | null; -}): ConversationState { +/** Reads the derived columns as one state. */ +function rowToState(r: { active: number; last_gate: string | null }): ConversationState { if (r.active) return "running"; - return (r.ask_seq ?? 0) > (r.settle_seq ?? 0) ? "blocked" : "idle"; + // The newest of the two kinds of event was the question, so nothing has + // answered it. + return r.last_gate === "tool-call" ? "blocked" : "idle"; } function rowToSummary(r: ConversationRow): ConversationSummary { @@ -983,10 +986,9 @@ export class Store { * history alone leaves the composer stuck mid-generation. */ conversationState(id: string): ConversationState { - const r = this.conversationStateStmt.get(id, id, id) as { + const r = this.conversationStateStmt.get(id, id) as { active: number; - ask_seq: number | null; - settle_seq: number | null; + last_gate: string | null; }; return rowToState(r); } diff --git a/tests/convstate.test.ts b/tests/convstate.test.ts index 1a0eaeb..a9e727c 100644 --- a/tests/convstate.test.ts +++ b/tests/convstate.test.ts @@ -57,6 +57,17 @@ test("search results carry the same state as the list", () => { expect(hit?.state).toBe("running"); }); +test("an ordinary tool call is not a question, and does not become one", () => { + const store = new Store(":memory:"); + seed(store, "c1"); + const jobId = job(store, "c1"); + store.appendAndBump("c1", Event.ToolCall, { toolName: "ask_user", toolCallId: "t1" }); + store.appendAndBump("c1", Event.User, { content: "free_text", runId: "r2" }); + store.appendAndBump("c1", Event.ToolCall, { toolName: "run_shell", toolCallId: "t2" }); + store.markDone(jobId); + expect(store.conversationState("c1")).toBe("idle"); +}); + test("a dangling message-start is not, by itself, a running conversation", () => { const store = new Store(":memory:"); seed(store, "c1");