From 9deffeefd13cfd597614285f0380d662176e0d5f Mon Sep 17 00:00:00 2001 From: deepanshu1422 Date: Wed, 26 Aug 2026 02:56:59 +0530 Subject: [PATCH 1/3] fix(web): keep progress chips visible for a minimum duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fast tool call (e.g. schedule_create) can complete before the browser gets a paint frame, so the terminal event immediately strips the live "working…" chip from the thread — the intermediate progress never actually renders, even though the run did real work. Track when a progress/steps message is first seen, and delay applying its terminal event until it's been visible for at least 700ms if it ever painted anything. The first-seen map is capped so a message whose terminal event never reaches this client doesn't linger forever. Co-Authored-By: Claude Sonnet 5 --- apps/web/src/pages/Shell.tsx | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 9a5590258..eec652685 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -43,6 +43,7 @@ import { isRunTerminalEvent, latestAnswerableAskMessageId, presetFromCron, + progressMessageId, SLASH_ACTIONS, type SlashActionId, serializeComposerPrompt, @@ -207,6 +208,14 @@ export function ShellPage() { const computerRef = useRef(null); const threadRefreshEpoch = useRef(0); const groupRefreshEpoch = useRef(0); + // First-seen timestamp per progress/steps message id, so a terminal event + // can't clear one before it's been visible for MIN_PROGRESS_VISIBLE_MS — + // a tool call fast enough to complete before the browser paints a frame + // would otherwise strip the "working…" chip before anyone ever sees it. + // Capped so a message whose terminal event never reaches this client (SSE + // drop mid-run, tab backgrounded through a reconnect, bot archived + // mid-run) doesn't leave its entry here forever. + const progressFirstSeenRef = useRef(new Map()); function commitSnapshot(next: ThreadSnapshot | null) { snapshotRef.current = next; @@ -696,6 +705,36 @@ export function ShellPage() { }; }, [active?.id, markBotReadIfVisible]); + const MIN_PROGRESS_VISIBLE_MS = 700; + const PROGRESS_FIRST_SEEN_LIMIT = 200; + + function trackProgressVisible(event: ProductEvent) { + if (event.type !== "agent.tool.called" && event.type !== "thread.progress") return; + const id = progressMessageId(event); + if (!progressFirstSeenRef.current.has(id)) { + progressFirstSeenRef.current.set(id, Date.now()); + if (progressFirstSeenRef.current.size > PROGRESS_FIRST_SEEN_LIMIT) { + const oldest = progressFirstSeenRef.current.keys().next().value; + if (oldest !== undefined) progressFirstSeenRef.current.delete(oldest); + } + } + } + + async function waitForProgressMinVisible(event: ProductEvent, signal: AbortSignal) { + if (!isRunTerminalEvent(event)) return; + const id = progressMessageId(event); + const firstSeen = progressFirstSeenRef.current.get(id); + progressFirstSeenRef.current.delete(id); + if (firstSeen === undefined) return; + const live = snapshotRef.current?.messages.find((message) => message.id === id); + const hasVisibleProgress = live?.blocks.some( + (block) => block.kind === "steps" || (block.kind === "progress" && block.text), + ); + if (!hasVisibleProgress) return; + const remaining = MIN_PROGRESS_VISIBLE_MS - (Date.now() - firstSeen); + if (remaining > 0) await abortableDelay(remaining, signal); + } + useEffect(() => { if (!active) return; if (!searchParams.get("m")) { @@ -724,6 +763,9 @@ export function ShellPage() { if (abort.signal.aborted) break; cursor = Math.max(cursor, event.seq); retryMs = 250; + trackProgressVisible(event); + await waitForProgressMinVisible(event, abort.signal); + if (abort.signal.aborted) break; applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef); if (event.type === "thread.cleared") { expandedHistoryThread.current = null; @@ -816,6 +858,9 @@ export function ShellPage() { if (abort.signal.aborted) break; cursor = Math.max(cursor, event.seq); retryMs = 250; + trackProgressVisible(event); + await waitForProgressMinVisible(event, abort.signal); + if (abort.signal.aborted) break; applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef); if (event.type === "thread.message.created" && event.payload.role === "bot") { readVisibleGroups.current.delete(groupId); From dc8392b1de6fe4191e03428f8d5362738dbf0320 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 21:47:23 +0000 Subject: [PATCH 2/3] fix(web): hold progress chip clear without blocking SSE Keep the 700ms min-visible chip by retaining live progress removal in commitSnapshot/retainMinVisibleLiveProgress instead of awaiting inside the subscribe loops. Terminal events, durable answers, and refreshes apply on time; only the live chip strip is deferred. Stop and waiting_input still clear immediately. Co-authored-by: Elie Steinbock --- apps/web/src/lib/thread-events.test.ts | 129 +++++++++++++++++++++++++ apps/web/src/lib/thread-events.ts | 117 ++++++++++++++++++++++ apps/web/src/pages/Shell.tsx | 110 ++++++++++++--------- 3 files changed, 308 insertions(+), 48 deletions(-) diff --git a/apps/web/src/lib/thread-events.test.ts b/apps/web/src/lib/thread-events.test.ts index b68644451..6e9f30026 100644 --- a/apps/web/src/lib/thread-events.test.ts +++ b/apps/web/src/lib/thread-events.test.ts @@ -11,11 +11,13 @@ import { computerPanelAutoBoot, computerTakeoverBlocked, isThreadSnapshotEvent, + MIN_PROGRESS_VISIBLE_MS, mergeThreadSnapshot, prependThreadMessagePage, reconcileRefreshedThread, reduceComputerStatus, reduceThreadSnapshot, + retainMinVisibleLiveProgress, userHoldsComputerControl, } from "./thread-events.js"; @@ -1136,6 +1138,133 @@ describe("computer event reduction", () => { }); }); +describe("min-visible live progress retention", () => { + it("keeps a just-painted chip when a durable answer would strip it", () => { + const run = threadRun("run-1"); + const live = { + ...message("progress:run-1", [ + { kind: "steps" as const, steps: [{ label: "Schedule", count: 1 }] }, + ]), + runId: run.id, + }; + const previous: ThreadSnapshot = { ...snapshot([live]), run, activeRuns: [run] }; + const firstSeen = new Map([[live.id, 1_000]]); + const durable = { + ...message("msg-1", [{ kind: "text", text: "Done" }], 5), + runId: run.id, + }; + const reduced = reduceThreadSnapshot( + previous, + event({ + type: "thread.message.created", + seq: 5, + runId: run.id, + payload: { messageId: durable.id, role: "bot", blocks: durable.blocks }, + }), + ); + + expect(reduced?.messages.map((item) => item.id)).toEqual(["msg-1"]); + + const held = retainMinVisibleLiveProgress(previous, reduced, firstSeen, 1_100); + expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1", "msg-1"]); + expect(held.clearAfterMs).toEqual([ + { id: "progress:run-1", delayMs: MIN_PROGRESS_VISIBLE_MS - 100 }, + ]); + expect(held.snapshot?.run).toEqual(run); + }); + + it("applies terminal run state immediately while holding only the chip", () => { + const run = threadRun("run-1"); + const live = { + ...message("progress:run-1", [{ kind: "progress" as const, text: "working…" }]), + runId: run.id, + }; + const previous: ThreadSnapshot = { ...snapshot([live]), run, activeRuns: [run] }; + const firstSeen = new Map([[live.id, 2_000]]); + const terminal = reduceThreadSnapshot( + previous, + event({ type: "run.completed", seq: 9, runId: run.id }), + ); + + expect(terminal?.run).toBeNull(); + expect(terminal?.messages).toEqual([]); + + const held = retainMinVisibleLiveProgress(previous, terminal, firstSeen, 2_050); + expect(held.snapshot?.run).toBeNull(); + expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1"]); + expect(held.clearAfterMs[0]?.delayMs).toBe(MIN_PROGRESS_VISIBLE_MS - 50); + }); + + it("does not hold beside waiting_input and does not hold after a thread clear", () => { + const run = threadRun("run-1"); + const live = { + ...message("progress:run-1", [{ kind: "progress" as const, text: "working…" }]), + runId: run.id, + }; + const withHistory: ThreadSnapshot = { + ...snapshot([message("m-1", [{ kind: "text", text: "hi" }], 1), live]), + run, + activeRuns: [run], + }; + const firstSeen = new Map([[live.id, 3_000]]); + + const waiting = reduceThreadSnapshot( + { ...snapshot([live]), run, activeRuns: [run] }, + event({ type: "run.waiting_input", seq: 6, runId: run.id }), + ); + const waitingHeld = retainMinVisibleLiveProgress( + { ...snapshot([live]), run, activeRuns: [run] }, + waiting, + firstSeen, + 3_010, + ); + expect(waitingHeld.snapshot?.messages).toEqual([]); + expect(waitingHeld.clearAfterMs).toEqual([]); + + firstSeen.set(live.id, 3_000); + const cleared = reduceThreadSnapshot( + withHistory, + event({ type: "thread.cleared", seq: 12, runId: undefined }), + ); + const clearedHeld = retainMinVisibleLiveProgress(withHistory, cleared, firstSeen, 3_010); + expect(clearedHeld.snapshot?.messages).toEqual([]); + expect(clearedHeld.clearAfterMs).toEqual([]); + }); + + it("survives a refresh that would otherwise clobber a held chip", () => { + const run = threadRun("run-1"); + const live = { + ...message("progress:run-1", [ + { kind: "steps" as const, steps: [{ label: "Tool", count: 1 }] }, + ]), + runId: run.id, + }; + const durable = { + ...message("msg-1", [{ kind: "text", text: "Answer" }], 8), + runId: run.id, + }; + const previous: ThreadSnapshot = { + ...snapshot([live, durable]), + cursor: 10, + run: null, + activeRuns: [], + }; + const refresh: ThreadSnapshot = { + ...snapshot([durable]), + cursor: 10, + run: null, + activeRuns: [], + }; + const firstSeen = new Map([[live.id, 4_000]]); + const reconciled = reconcileRefreshedThread(previous, refresh, null); + expect(reconciled.snapshot.messages.map((item) => item.id)).toEqual(["msg-1"]); + + const held = retainMinVisibleLiveProgress(previous, reconciled.snapshot, firstSeen, 4_200); + expect(held.snapshot?.messages.map((item) => item.id)).toEqual(["progress:run-1", "msg-1"]); + expect(held.clearAfterMs[0]?.delayMs).toBe(MIN_PROGRESS_VISIBLE_MS - 200); + }); +}); + function snapshot(messages: ThreadMessage[], olderCursor: number | null = null): ThreadSnapshot { return { botId: "bot-1", diff --git a/apps/web/src/lib/thread-events.ts b/apps/web/src/lib/thread-events.ts index d05be63a4..c7b3a1525 100644 --- a/apps/web/src/lib/thread-events.ts +++ b/apps/web/src/lib/thread-events.ts @@ -63,6 +63,123 @@ function takeLiveMessage( return { previous, remaining }; } +/** Minimum time a painted progress/steps chip should stay on screen. */ +export const MIN_PROGRESS_VISIBLE_MS = 700; +/** Cap first-seen entries so a drop/reconnect cannot grow the map forever. */ +export const PROGRESS_FIRST_SEEN_LIMIT = 200; + +export function liveProgressIsVisible(message: ThreadMessage): boolean { + return message.blocks.some( + (block) => block.kind === "steps" || (block.kind === "progress" && Boolean(block.text)), + ); +} + +export function noteProgressFirstSeen( + messages: readonly ThreadMessage[], + firstSeen: Map, + now: number, + limit = PROGRESS_FIRST_SEEN_LIMIT, +): void { + for (const message of messages) { + if (!message.id.startsWith("progress:") || !liveProgressIsVisible(message)) continue; + if (firstSeen.has(message.id)) continue; + firstSeen.set(message.id, now); + while (firstSeen.size > limit) { + const oldest = firstSeen.keys().next().value; + if (oldest === undefined) break; + firstSeen.delete(oldest); + } + } +} + +/** + * Keep a just-painted live progress/steps chip when an event or refresh would strip it + * before MIN_PROGRESS_VISIBLE_MS. Terminal run state and durable messages still apply; + * only the live-message removal is deferred. + */ +export function retainMinVisibleLiveProgress( + previous: ThreadSnapshot | null, + next: ThreadSnapshot | null, + firstSeen: Map, + now = Date.now(), + minVisibleMs = MIN_PROGRESS_VISIBLE_MS, +): { snapshot: ThreadSnapshot | null; clearAfterMs: Array<{ id: string; delayMs: number }> } { + if (!next) return { snapshot: next, clearAfterMs: [] }; + noteProgressFirstSeen(next.messages, firstSeen, now); + if (!previous || previous.threadId !== next.threadId) { + return { snapshot: next, clearAfterMs: [] }; + } + + // thread.cleared wipes durable history; never reattach live chips onto an empty transcript. + const previousHadDurable = previous.messages.some( + (message) => !isTransientThreadMessage(message), + ); + const nextHasDurable = next.messages.some((message) => !isTransientThreadMessage(message)); + if (previousHadDurable && !nextHasDurable) { + for (const message of previous.messages) { + if (message.id.startsWith("progress:")) firstSeen.delete(message.id); + } + return { snapshot: next, clearAfterMs: [] }; + } + + const nextIds = new Set(next.messages.map((message) => message.id)); + const held: ThreadMessage[] = []; + const clearAfterMs: Array<{ id: string; delayMs: number }> = []; + + for (const message of previous.messages) { + if (!message.id.startsWith("progress:") || nextIds.has(message.id)) continue; + if (!liveProgressIsVisible(message)) continue; + const seenAt = firstSeen.get(message.id); + if (seenAt === undefined) continue; + + // Ask pauses must not leave "working…" beside waiting_input cards. + const runId = message.runId; + const nextRun = runId + ? (next.activeRuns?.find((candidate) => candidate.id === runId) ?? + (next.run?.id === runId ? next.run : undefined)) + : undefined; + if (nextRun?.status === "waiting_input") { + firstSeen.delete(message.id); + continue; + } + + const remaining = minVisibleMs - (now - seenAt); + if (remaining <= 0) { + firstSeen.delete(message.id); + continue; + } + held.push(message); + clearAfterMs.push({ id: message.id, delayMs: remaining }); + } + + if (held.length === 0) return { snapshot: next, clearAfterMs: [] }; + return { + snapshot: { ...next, messages: insertHeldLiveProgress(next.messages, held) }, + clearAfterMs, + }; +} + +function isTransientThreadMessage(message: ThreadMessage): boolean { + return message.id.startsWith("progress:") || message.id.startsWith("subagent:"); +} + +function insertHeldLiveProgress( + messages: readonly ThreadMessage[], + held: readonly ThreadMessage[], +): ThreadMessage[] { + const next = [...messages]; + for (const live of held) { + if (next.some((message) => message.id === live.id)) continue; + const durableIndex = next.findIndex( + (message) => + Boolean(live.runId) && message.runId === live.runId && !message.id.startsWith("progress:"), + ); + if (durableIndex >= 0) next.splice(durableIndex, 0, live); + else next.push(live); + } + return next; +} + const computerStates: ReadonlySet = new Set([ "stopped", "booting", diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index eec652685..7f2cf031f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -43,7 +43,6 @@ import { isRunTerminalEvent, latestAnswerableAskMessageId, presetFromCron, - progressMessageId, SLASH_ACTIONS, type SlashActionId, serializeComposerPrompt, @@ -116,6 +115,7 @@ import { reconcileRefreshedThread, reduceComputerStatus, reduceThreadSnapshot, + retainMinVisibleLiveProgress, userHoldsComputerControl, } from "../lib/thread-events"; import { speaker } from "../lib/tts"; @@ -208,18 +208,57 @@ export function ShellPage() { const computerRef = useRef(null); const threadRefreshEpoch = useRef(0); const groupRefreshEpoch = useRef(0); - // First-seen timestamp per progress/steps message id, so a terminal event - // can't clear one before it's been visible for MIN_PROGRESS_VISIBLE_MS — - // a tool call fast enough to complete before the browser paints a frame - // would otherwise strip the "working…" chip before anyone ever sees it. - // Capped so a message whose terminal event never reaches this client (SSE - // drop mid-run, tab backgrounded through a reconnect, bot archived - // mid-run) doesn't leave its entry here forever. + // First-seen timestamp per progress/steps message id. Commit retains a + // just-painted chip for MIN_PROGRESS_VISIBLE_MS when events/refreshes would + // strip it — without stalling the SSE subscribe loop. Capped so a message + // whose clear never reaches this client doesn't linger forever. const progressFirstSeenRef = useRef(new Map()); + const progressClearTimersRef = useRef(new Map>()); + + function clearProgressHold(id: string) { + progressFirstSeenRef.current.delete(id); + const timer = progressClearTimersRef.current.get(id); + if (timer !== undefined) { + clearTimeout(timer); + progressClearTimersRef.current.delete(id); + } + } + + function dropLiveProgressHolds(messages: readonly ThreadMessage[]) { + for (const message of messages) { + if (message.id.startsWith("progress:")) clearProgressHold(message.id); + } + } + + function dropAllProgressHolds() { + for (const timer of progressClearTimersRef.current.values()) clearTimeout(timer); + progressClearTimersRef.current.clear(); + progressFirstSeenRef.current.clear(); + } function commitSnapshot(next: ThreadSnapshot | null) { - snapshotRef.current = next; - setSnapshot(next); + const { snapshot, clearAfterMs } = retainMinVisibleLiveProgress( + snapshotRef.current, + next, + progressFirstSeenRef.current, + ); + snapshotRef.current = snapshot; + setSnapshot(snapshot); + for (const { id, delayMs } of clearAfterMs) { + const existing = progressClearTimersRef.current.get(id); + if (existing !== undefined) clearTimeout(existing); + const timer = setTimeout(() => { + progressClearTimersRef.current.delete(id); + progressFirstSeenRef.current.delete(id); + const current = snapshotRef.current; + if (!current?.messages.some((message) => message.id === id)) return; + commitSnapshot({ + ...current, + messages: current.messages.filter((message) => message.id !== id), + }); + }, delayMs); + progressClearTimersRef.current.set(id, timer); + } } function commitComputer(next: ComputerStatus | null) { @@ -705,35 +744,11 @@ export function ShellPage() { }; }, [active?.id, markBotReadIfVisible]); - const MIN_PROGRESS_VISIBLE_MS = 700; - const PROGRESS_FIRST_SEEN_LIMIT = 200; - - function trackProgressVisible(event: ProductEvent) { - if (event.type !== "agent.tool.called" && event.type !== "thread.progress") return; - const id = progressMessageId(event); - if (!progressFirstSeenRef.current.has(id)) { - progressFirstSeenRef.current.set(id, Date.now()); - if (progressFirstSeenRef.current.size > PROGRESS_FIRST_SEEN_LIMIT) { - const oldest = progressFirstSeenRef.current.keys().next().value; - if (oldest !== undefined) progressFirstSeenRef.current.delete(oldest); - } - } - } - - async function waitForProgressMinVisible(event: ProductEvent, signal: AbortSignal) { - if (!isRunTerminalEvent(event)) return; - const id = progressMessageId(event); - const firstSeen = progressFirstSeenRef.current.get(id); - progressFirstSeenRef.current.delete(id); - if (firstSeen === undefined) return; - const live = snapshotRef.current?.messages.find((message) => message.id === id); - const hasVisibleProgress = live?.blocks.some( - (block) => block.kind === "steps" || (block.kind === "progress" && block.text), - ); - if (!hasVisibleProgress) return; - const remaining = MIN_PROGRESS_VISIBLE_MS - (Date.now() - firstSeen); - if (remaining > 0) await abortableDelay(remaining, signal); - } + useEffect(() => { + return () => { + dropAllProgressHolds(); + }; + }, []); useEffect(() => { if (!active) return; @@ -744,6 +759,7 @@ export function ShellPage() { setScreenUrl(null); expandedHistoryThread.current = null; historyEpoch.current += 1; + dropAllProgressHolds(); const abort = new AbortController(); void (async () => { const primed = bootstrappedThread.current; @@ -763,9 +779,6 @@ export function ShellPage() { if (abort.signal.aborted) break; cursor = Math.max(cursor, event.seq); retryMs = 250; - trackProgressVisible(event); - await waitForProgressMinVisible(event, abort.signal); - if (abort.signal.aborted) break; applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef); if (event.type === "thread.cleared") { expandedHistoryThread.current = null; @@ -818,6 +831,7 @@ export function ShellPage() { if (!groupId || !activeGroup) return; manuallyUnread.current.delete(activeGroup.id); readVisibleGroups.current.delete(groupId); + dropAllProgressHolds(); const markVisibleGroupRead = () => { if ( document.visibilityState !== "visible" || @@ -858,9 +872,6 @@ export function ShellPage() { if (abort.signal.aborted) break; cursor = Math.max(cursor, event.seq); retryMs = 250; - trackProgressVisible(event); - await waitForProgressMinVisible(event, abort.signal); - if (abort.signal.aborted) break; applyThreadEvent(event, commitSnapshot, commitComputer, snapshotRef, computerRef); if (event.type === "thread.message.created" && event.payload.role === "bot") { readVisibleGroups.current.delete(groupId); @@ -1212,9 +1223,11 @@ export function ShellPage() { } // Stop has no terminal event; clear run UI before refresh races with in-flight gets. if (activeGroupId.current === groupTarget) { - updateSnapshot((prev) => - prev && prev.groupId === groupTarget ? clearActiveThreadRuns(prev) : prev, - ); + updateSnapshot((prev) => { + if (!prev || prev.groupId !== groupTarget) return prev; + dropLiveProgressHolds(prev.messages); + return clearActiveThreadRuns(prev); + }); } await refreshGroupThreadRef.current(groupTarget).catch(() => undefined); return; @@ -1235,6 +1248,7 @@ export function ShellPage() { if (activeBotId.current === botTarget) { updateSnapshot((prev) => { if (!prev || (prev.botId !== botTarget && prev.botId)) return prev; + dropLiveProgressHolds(prev.messages); return clearActiveThreadRuns(prev); }); const currentComputer = computerRef.current; From 51f98f9266d546a2e17087976e9c35894f1f9ccf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 21:54:28 +0000 Subject: [PATCH 3/3] chore: retrigger CI after transient action setup failures Lint and Postgres journeys failed at Set up job downloading pnpm/action-setup (DNS), not on our changes. Retrigger the suite. Co-authored-by: Elie Steinbock