From 1ff9ebadfaa22d4e351320d1edf61b135493ae38 Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 5 Nov 2026 14:45:25 +0000 Subject: [PATCH 1/3] fix(web): keep steered turn layout stable when it settles --- .../chat/MessagesTimeline.logic.test.ts | 38 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 12 +++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index eaab41ecc4fe9..e73f3861fd0d2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3165,6 +3165,44 @@ describe("v2 run and attempt history", () => { supportsConversationRollback: false, }; + const liveInput = { + ...common, + timelineEntries: timelineEntries.filter((entry) => entry.kind !== "event"), + isWorking: true, + activeTurnStartedAt: common.latestRun.startedAt, + }; + const liveRows = applyWorkingWindow(deriveMessagesTimelineRows(liveInput), liveInput); + expect(liveRows.some((row) => row.kind === "attempt-fold")).toBe(false); + expect(liveRows.find((row) => row.id === "initial-user-entry")).toMatchObject({ + groupedWithNext: true, + }); + const completedInput = { + ...common, + latestRun: { + ...common.latestRun, + status: "completed" as const, + startedAt: "2026-01-01T00:00:03Z", + completedAt: "2026-01-01T00:00:08Z", + }, + timelineEntries: timelineEntries.map((entry) => + entry.kind === "message" && entry.id === "active-assistant-entry" + ? { + ...entry, + message: { ...entry.message, streaming: false, updatedAt: "2026-01-01T00:00:08Z" }, + } + : entry, + ), + }; + const completedRows = deriveMessagesTimelineRows(completedInput); + expect(completedRows.find((row) => row.kind === "turn-fold")).toMatchObject({ + label: "Worked for 8s", + }); + expect( + deriveMessagesTimelineRows({ ...completedInput, latestRun: null }).find( + (row) => row.kind === "turn-fold", + ), + ).toMatchObject({ label: "Worked for 8s" }); + const collapsedRows = deriveMessagesTimelineRows(common); expect(collapsedRows.map((row) => row.id)).toEqual([ "context-handoff-entry", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c30185adfa22b..c89af0b4b02d4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1391,7 +1391,12 @@ function deriveTurnFolds(input: { const lastEntryEnd = timelineEntryEnd(lastEntry); const elapsedMs = input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt - ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) + ? computeElapsedMs( + prompt?.attempt?.status === "superseded" + ? (group.startBoundary ?? input.latestRun.startedAt) + : input.latestRun.startedAt, + input.latestRun.completedAt, + ) : computeElapsedMs( group.startBoundary ?? firstEntry.createdAt, maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? @@ -2177,7 +2182,10 @@ export function deriveMessagesTimelineRows(input: { } const supersededFold = supersededFoldsByAnchorEntryId.get(timelineEntry.id); - if (supersededFold) { + if ( + supersededFold && + !(input.isWorking && activeVisualResponseRunIds.has(supersededFold.runId)) + ) { nextRows.push({ kind: "attempt-fold", deferredWorkItemIds: supersededFold.deferredWorkItemIds, From c307ec25fd5ecf6b9001d2cc720ab96775d40fd6 Mon Sep 17 00:00:00 2001 From: flamboh Date: Thu, 5 Nov 2026 18:54:50 +0000 Subject: [PATCH 2/3] fix(web): steered turns keep the cut-off attempt and one timer while live --- .../chat/MessagesTimeline.browser.test.tsx | 1 + .../chat/MessagesTimeline.logic.test.ts | 91 +++++++++++++++++-- .../components/chat/MessagesTimeline.logic.ts | 73 +++++++++++---- apps/web/src/session-logic.ts | 2 +- 4 files changed, 143 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index a1921a2d9a81a..12ca835dc14f7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1056,6 +1056,7 @@ describe("MessagesTimeline citations", () => { attemptOrdinal: ordinal, rootNodeId: `node-${id}` as never, status, + startedAt: null, completedAt: null, }); const superseded = attempt("attempt-1", 1, "superseded"); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e73f3861fd0d2..0f6c3364c6891 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -401,6 +401,7 @@ describe("deriveMessagesTimelineRows", () => { status: "superseded" as const, attemptOrdinal: 0, rootNodeId: "old-root" as never, + startedAt: null, completedAt: null, }, } @@ -3038,6 +3039,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 1, rootNodeId: "node-attempt-1" as never, status: "superseded" as const, + startedAt: DateTime.makeUnsafe("2026-01-01T00:00:00Z"), completedAt: null, }; const activeAttempt = { @@ -3046,6 +3048,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 2, rootNodeId: "node-attempt-2" as never, status: "running" as const, + startedAt: DateTime.makeUnsafe("2026-01-01T00:00:05Z"), completedAt: null, }; const timelineEntries = [ @@ -3169,13 +3172,36 @@ describe("v2 run and attempt history", () => { ...common, timelineEntries: timelineEntries.filter((entry) => entry.kind !== "event"), isWorking: true, - activeTurnStartedAt: common.latestRun.startedAt, + activeTurnStartedAt: "2026-01-01T00:00:05Z", }; const liveRows = applyWorkingWindow(deriveMessagesTimelineRows(liveInput), liveInput); - expect(liveRows.some((row) => row.kind === "attempt-fold")).toBe(false); + expect(liveRows.map((row) => row.id)).toEqual([ + "initial-user-entry", + "steer-user-entry", + `attempt-fold:${supersededAttemptId}`, + "working-indicator-row", + "active-assistant-entry", + ]); expect(liveRows.find((row) => row.id === "initial-user-entry")).toMatchObject({ groupedWithNext: true, }); + expect(liveRows.find((row) => row.kind === "working")?.createdAt).toBe( + "2026-01-01T00:00:00.000Z", + ); + const expandedLiveInput = { ...liveInput, expandedAttemptIds: new Set([supersededAttemptId]) }; + expect( + applyWorkingWindow(deriveMessagesTimelineRows(expandedLiveInput), expandedLiveInput).map( + (row) => row.id, + ), + ).toEqual([ + "initial-user-entry", + "steer-user-entry", + `attempt-fold:${supersededAttemptId}`, + "superseded-assistant-entry", + "superseded-work-entry", + "working-indicator-row", + "active-assistant-entry", + ]); const completedInput = { ...common, latestRun: { @@ -3291,6 +3317,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("node-attempt-1"), status: "superseded" as const, + startedAt: null, completedAt: DateTime.makeUnsafe(at(10)), }; const nextAttempt = { @@ -3299,6 +3326,7 @@ describe("v2 run and attempt history", () => { attemptOrdinal: 2, rootNodeId: NodeId.make("node-attempt-2"), status: "completed" as const, + startedAt: null, completedAt: DateTime.makeUnsafe(at(14)), }; const assistantEntry = ( @@ -4449,6 +4477,7 @@ describe("failed turn transcript", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + startedAt: null, completedAt: null, }, }, @@ -4550,13 +4579,13 @@ describe("working window", () => { createdAt: time(second), entry: { id, createdAt: time(second), runId, tone: "tool" as const, ...entry }, }); - const run = (entries: ReadonlyArray) => { + const run = (entries: ReadonlyArray, activeTurnStartedAt = time(0)) => { const input = { timelineEntries: entries as never, latestRun: { runId, status: "running" as const, startedAt: time(0), completedAt: null }, runningRunId: runId, isWorking: true, - activeTurnStartedAt: time(0), + activeTurnStartedAt, turnDiffSummaries: [], supportsConversationRollback: false, }; @@ -4568,7 +4597,7 @@ describe("working window", () => { ); it("moves the running turn's work under the bar and keeps steers and replies above it", () => { - const rows = run([ + const entries = [ prompt("prompt", 0), work("edit", 2, { label: "Edited a.ts", @@ -4599,7 +4628,8 @@ describe("working window", () => { itemType: "command_execution", toolLifecycleStatus: "inProgress", }), - ]); + ]; + const rows = run(entries); expect(rows.map((row) => row.id)).toEqual([ "prompt", "reply", @@ -4612,6 +4642,54 @@ describe("working window", () => { // Sending a steer re-keys the window, which closes an open history. expect(window?.key).toBe("steer"); expect(window?.historyOpen).toBe(false); + + const [promptEntry, edit, reply, , command] = entries; + const pendingSteer = prompt("steer", 3); + const pendingSteerEntry = { + ...pendingSteer, + message: { ...pendingSteer.message, inputIntent: "steer" as const, runId: null }, + }; + for (const pending of [ + [promptEntry, edit, reply, pendingSteerEntry, command], + [promptEntry, edit, reply, pendingSteerEntry], + ]) { + const pendingRows = run(pending); + expect(pendingRows.map((row) => row.id)).toEqual([ + "prompt", + "steer", + "working-indicator-row", + ]); + expect(workingRow(pendingRows)?.window?.items.map((item) => item.id)).toEqual( + ["edit", "reply", "command"].slice(0, pending.length - 2), + ); + } + + const attempt = (ordinal: number, status: "superseded" | "running", second: number) => ({ + id: RunAttemptId.make(`attempt-${ordinal}`), + runId, + attemptOrdinal: ordinal, + rootNodeId: NodeId.make(`node-${ordinal}`), + status, + startedAt: DateTime.makeUnsafe(time(second)), + completedAt: status === "superseded" ? DateTime.makeUnsafe(time(3)) : null, + }); + const restarted = run( + entries.map((entry, index) => ({ + ...entry, + attempt: index < 3 ? attempt(1, "superseded", 0) : attempt(2, "running", 3), + })), + time(3), + ); + expect(restarted.map((row) => row.id)).toEqual([ + "prompt", + "steer", + "attempt-fold:attempt-1", + "working-indicator-row", + ]); + expect(workingRow(restarted)).toMatchObject({ + createdAt: time(0), + window: { diff: { additions: 84, deletions: 22 } }, + }); }); it("groups a steer with its prompt once the bar moves below both", () => { @@ -5404,6 +5482,7 @@ describe("live subagents after their parent turn settles", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + startedAt: null, completedAt: null, }; const entries = deriveTimelineEntriesFromVisibleTurnItems({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c89af0b4b02d4..15358889aa83d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -502,6 +502,24 @@ function maxIsoTimestamp(a: string | null, b: string | null): string | null { return bMs > aMs ? b : a; } +function minIsoTimestamp(a: string | null, b: string | null): string | null { + return a === null || b === null ? (a ?? b) : maxIsoTimestamp(a, b) === a ? b : a; +} + +function runFirstAttemptStartedAt( + entries: ReadonlyArray, + runId: RunId | null, +): string | null { + let first: DateTime.Utc | null = null; + for (const entry of entries) { + const startedAt = entry.attempt?.runId === runId ? entry.attempt.startedAt : null; + if (startedAt != null && (first === null || DateTime.isLessThan(startedAt, first))) { + first = startedAt; + } + } + return first === null ? null : DateTime.formatIso(first); +} + export type TimelineLatestRun = Pick< ThreadRunSummary, "runId" | "status" | "startedAt" | "completedAt" | "turnEnded" @@ -974,6 +992,7 @@ function steerReplyEntryIds(entries: ReadonlyArray): ReadonlySet< if (timelineEntryStartsResponse(entry)) { lastReply = null; } else if (timelineEntryIsSteer(entry)) { + if (entry.kind !== "message" || entry.message.runId == null) continue; if (lastReply !== null) ids.add(lastReply); lastReply = null; } else if ( @@ -1392,9 +1411,10 @@ function deriveTurnFolds(input: { const elapsedMs = input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt ? computeElapsedMs( - prompt?.attempt?.status === "superseded" - ? (group.startBoundary ?? input.latestRun.startedAt) - : input.latestRun.startedAt, + minIsoTimestamp( + runFirstAttemptStartedAt(input.timelineEntries, runId), + input.latestRun.startedAt, + ) ?? input.latestRun.startedAt, input.latestRun.completedAt, ) : computeElapsedMs( @@ -1799,6 +1819,10 @@ export function deriveMessagesTimelineRows(input: { } } const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null); + const activeTurnStartedAt = minIsoTimestamp( + input.activeTurnStartedAt ?? null, + runFirstAttemptStartedAt(timelineEntries, unsettledRunId), + ); const revertTurnCountByUserMessageId = input.supportsConversationRollback ? deriveRevertTurnCountByUserMessageId({ timelineEntries: input.timelineEntries, @@ -2064,8 +2088,7 @@ export function deriveMessagesTimelineRows(input: { id: "working-indicator-row", createdAt: (compactsOnReturn ? undefined : resumeCompactionEntry?.entry.createdAt) ?? - input.activeTurnStartedAt ?? - null, + activeTurnStartedAt, ...(activeCompactionRunId !== null ? { compacting: true as const } : {}), }); }; @@ -2182,10 +2205,7 @@ export function deriveMessagesTimelineRows(input: { } const supersededFold = supersededFoldsByAnchorEntryId.get(timelineEntry.id); - if ( - supersededFold && - !(input.isWorking && activeVisualResponseRunIds.has(supersededFold.runId)) - ) { + if (supersededFold) { nextRows.push({ kind: "attempt-fold", deferredWorkItemIds: supersededFold.deferredWorkItemIds, @@ -2580,7 +2600,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, + createdAt: activeTurnStartedAt, groupId, expanded, }); @@ -2597,7 +2617,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, + createdAt: activeTurnStartedAt, }); } } @@ -2917,17 +2937,24 @@ export function applyWorkingWindow( ); let interruptRequested = false; let interruptSettled = false; + let itemsBeforeLatestPrompt = 0; + const supersededWork: WorkLogEntry[] = []; for (let index = boundaryIndex + 1; index < entries.length; index += 1) { const entry = entries[index]!; if (entry.kind === "message" && entry.message.role === "user") { key = entry.id; + itemsBeforeLatestPrompt = items.length; continue; } - if (entry.attempt?.status === "superseded" || replyIds.has(entry.id)) continue; const runId = entry.kind === "event" ? entry.projectedItem.item.runId : timelineEntryRunId(entry); // A provider-native subagent thread's live work has no app run. if (runId === null ? input.runlessWorkActive !== true : !runIds.has(runId)) continue; + if (entry.attempt?.status === "superseded") { + if (entry.kind === "work") supersededWork.push(entry.entry); + continue; + } + if (replyIds.has(entry.id)) continue; if (entry.kind === "work") { const item = entry.entry.projectedItem?.item; if ( @@ -2990,7 +3017,10 @@ export function applyWorkingWindow( } } - const answer = items.at(-1)?.kind === "text" ? items.pop() : undefined; + const answer = + items.at(-1)?.kind === "text" && items.length > itemsBeforeLatestPrompt + ? items.pop() + : undefined; const ids = collectWindowIds(items); const windowStartIndex = Math.max( workingIndex, @@ -2998,15 +3028,23 @@ export function applyWorkingWindow( ); const keptRows = (start: number, end?: number) => rows.slice(start, end).filter((row) => !rowIsReplacedByWindow(row, ids)); - const before = keptRows(workingIndex + 1, windowStartIndex + 1); + const before: MessagesTimelineRow[] = []; + const supersededAttempts: MessagesTimelineRow[] = []; + let inSupersededAttempt = false; + for (const row of keptRows(workingIndex + 1, windowStartIndex + 1)) { + if (row.kind === "attempt-fold") inSupersededAttempt = runIds.has(row.runId); + else if (row.kind === "message" && row.message.role === "user") inSupersededAttempt = false; + (inSupersededAttempt ? supersededAttempts : before).push(row); + } const after = keptRows(windowStartIndex + 1); const window: WorkingWindow = { key, items, compactCommand: compactCommandRunIds.has(unsettledRunId), - diff: sumFileChangeLineStat( - items.flatMap((item) => (item.kind === "work" ? [item.entry] : [])), - ), + diff: sumFileChangeLineStat([ + ...supersededWork, + ...items.flatMap((item) => (item.kind === "work" ? [item.entry] : [])), + ]), failedTests: deriveFailedTests(items), retryAttempt: deriveRetryAttempt(items), stopping: interruptRequested && !interruptSettled, @@ -3017,6 +3055,7 @@ export function applyWorkingWindow( return withPromptGrouping([ ...rows.slice(0, workingIndex), ...before, + ...supersededAttempts, { ...workingRow, window }, ...after.map((row) => (row.continuesWorkLog ? { ...row, continuesWorkLog: false } : row)), ]); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index c46065fa3ede2..2555c92760a6c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -98,7 +98,7 @@ export type PendingUserInput = ThreadPendingUserInput; export type TimelineAttempt = Pick< OrchestrationV2RunAttempt, - "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "completedAt" + "id" | "runId" | "attemptOrdinal" | "rootNodeId" | "status" | "startedAt" | "completedAt" >; export type TimelineEntry = ( From 1bd080fe85ba527f45e8b5fc8573c28320ecbdf5 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 7 Nov 2026 19:26:19 +0000 Subject: [PATCH 3/3] test(web): drop steer cases that repeat the working-window case --- .../chat/MessagesTimeline.logic.test.ts | 60 +++---------------- 1 file changed, 7 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0f6c3364c6891..0b502f2f985d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3168,40 +3168,6 @@ describe("v2 run and attempt history", () => { supportsConversationRollback: false, }; - const liveInput = { - ...common, - timelineEntries: timelineEntries.filter((entry) => entry.kind !== "event"), - isWorking: true, - activeTurnStartedAt: "2026-01-01T00:00:05Z", - }; - const liveRows = applyWorkingWindow(deriveMessagesTimelineRows(liveInput), liveInput); - expect(liveRows.map((row) => row.id)).toEqual([ - "initial-user-entry", - "steer-user-entry", - `attempt-fold:${supersededAttemptId}`, - "working-indicator-row", - "active-assistant-entry", - ]); - expect(liveRows.find((row) => row.id === "initial-user-entry")).toMatchObject({ - groupedWithNext: true, - }); - expect(liveRows.find((row) => row.kind === "working")?.createdAt).toBe( - "2026-01-01T00:00:00.000Z", - ); - const expandedLiveInput = { ...liveInput, expandedAttemptIds: new Set([supersededAttemptId]) }; - expect( - applyWorkingWindow(deriveMessagesTimelineRows(expandedLiveInput), expandedLiveInput).map( - (row) => row.id, - ), - ).toEqual([ - "initial-user-entry", - "steer-user-entry", - `attempt-fold:${supersededAttemptId}`, - "superseded-assistant-entry", - "superseded-work-entry", - "working-indicator-row", - "active-assistant-entry", - ]); const completedInput = { ...common, latestRun: { @@ -3223,11 +3189,6 @@ describe("v2 run and attempt history", () => { expect(completedRows.find((row) => row.kind === "turn-fold")).toMatchObject({ label: "Worked for 8s", }); - expect( - deriveMessagesTimelineRows({ ...completedInput, latestRun: null }).find( - (row) => row.kind === "turn-fold", - ), - ).toMatchObject({ label: "Worked for 8s" }); const collapsedRows = deriveMessagesTimelineRows(common); expect(collapsedRows.map((row) => row.id)).toEqual([ @@ -4649,20 +4610,13 @@ describe("working window", () => { ...pendingSteer, message: { ...pendingSteer.message, inputIntent: "steer" as const, runId: null }, }; - for (const pending of [ - [promptEntry, edit, reply, pendingSteerEntry, command], - [promptEntry, edit, reply, pendingSteerEntry], - ]) { - const pendingRows = run(pending); - expect(pendingRows.map((row) => row.id)).toEqual([ - "prompt", - "steer", - "working-indicator-row", - ]); - expect(workingRow(pendingRows)?.window?.items.map((item) => item.id)).toEqual( - ["edit", "reply", "command"].slice(0, pending.length - 2), - ); - } + const pendingRows = run([promptEntry, edit, reply, pendingSteerEntry, command]); + expect(pendingRows.map((row) => row.id)).toEqual(["prompt", "steer", "working-indicator-row"]); + expect(workingRow(pendingRows)?.window?.items.map((item) => item.id)).toEqual([ + "edit", + "reply", + "command", + ]); const attempt = (ordinal: number, status: "superseded" | "running", second: number) => ({ id: RunAttemptId.make(`attempt-${ordinal}`),