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 eaab41ecc4fe9..0b502f2f985d9 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 = [ @@ -3165,6 +3168,28 @@ describe("v2 run and attempt history", () => { supportsConversationRollback: false, }; + 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", + }); + const collapsedRows = deriveMessagesTimelineRows(common); expect(collapsedRows.map((row) => row.id)).toEqual([ "context-handoff-entry", @@ -3253,6 +3278,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 = { @@ -3261,6 +3287,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 = ( @@ -4411,6 +4438,7 @@ describe("failed turn transcript", () => { attemptOrdinal: 1, rootNodeId: NodeId.make("superseded-root"), status: "superseded" as const, + startedAt: null, completedAt: null, }, }, @@ -4512,13 +4540,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, }; @@ -4530,7 +4558,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", @@ -4561,7 +4589,8 @@ describe("working window", () => { itemType: "command_execution", toolLifecycleStatus: "inProgress", }), - ]); + ]; + const rows = run(entries); expect(rows.map((row) => row.id)).toEqual([ "prompt", "reply", @@ -4574,6 +4603,47 @@ 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 }, + }; + 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}`), + 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", () => { @@ -5366,6 +5436,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 c30185adfa22b..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 ( @@ -1391,7 +1410,13 @@ 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( + minIsoTimestamp( + runFirstAttemptStartedAt(input.timelineEntries, runId), + input.latestRun.startedAt, + ) ?? input.latestRun.startedAt, + input.latestRun.completedAt, + ) : computeElapsedMs( group.startBoundary ?? firstEntry.createdAt, maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? @@ -1794,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, @@ -2059,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 } : {}), }); }; @@ -2572,7 +2600,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, + createdAt: activeTurnStartedAt, groupId, expanded, }); @@ -2589,7 +2617,7 @@ export function deriveMessagesTimelineRows(input: { nextRows.push({ kind: "thinking", id: LIVE_ACTIVITY_ROW_ID, - createdAt: input.activeTurnStartedAt ?? null, + createdAt: activeTurnStartedAt, }); } } @@ -2909,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 ( @@ -2982,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, @@ -2990,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, @@ -3009,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 = (