From 45e5077759038d82746f058655d6a178308ec3e9 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 2 Nov 2026 04:01:42 +0000 Subject: [PATCH 1/3] fix(web): steer segments no longer show a diff total Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 46 +++++++++++++++++-- .../components/chat/MessagesTimeline.logic.ts | 4 +- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7cd4adb993146..2c10d1b40081d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1444,7 +1444,28 @@ describe("deriveMessagesTimelineRows", () => { kind: "work", id, createdAt: time(second), - entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" }, + entry: { + id, + createdAt: time(second), + runId, + label: "Edited a.ts", + tone: "tool", + projectedItem: { + position: 0, + visibility: "local", + sourceThreadId: ThreadId.make("thread"), + sourceItemId: id, + item: { + id, + runId, + status: "completed", + parentItemId: null, + type: "file_change", + additions: 40, + deletions: 0, + }, + } as never, + }, }); const rows = (expandedRunIds: ReadonlySet) => deriveMessagesTimelineRows({ @@ -1458,7 +1479,17 @@ describe("deriveMessagesTimelineRows", () => { latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, isWorking: false, expandedRunIds, - turnDiffSummaries: [], + turnDiffSummaries: [ + { + runId, + completedAt: time(30), + assistantMessageId: MessageId.make("final"), + checkpointTurnCount: 1, + checkpointRef: "checkpoint-1" as never, + status: "ready", + files: [{ path: "a.ts", kind: "modified", additions: 30, deletions: 2 }], + }, + ], supportsConversationRollback: false, }); @@ -1471,8 +1502,15 @@ describe("deriveMessagesTimelineRows", () => { `turn-fold:${segmentKey}`, "final", ]); - expect(collapsed[1]).toMatchObject({ label: "Worked for 30s" }); - expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10) }); + expect(collapsed[1]).toMatchObject({ + label: "Worked for 30s", + diff: { additions: 30, deletions: 2 }, + }); + expect(collapsed[3]).toMatchObject({ + label: "Continued for 20s", + createdAt: time(10), + diff: null, + }); expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ "prompt", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7d493e0ce7197..03cb3a9565691 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1423,9 +1423,7 @@ function deriveTurnFolds(input: { label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued", // The turn summary owns its terminal message; each segment keeps its own disclosure. ending: failed ? ending : "worked", - diff: sumFileChangeLineStat( - entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])), - ), + diff: null, endingEntryIds: new Set( entries.flatMap((entry) => (endingEntryIds.has(entry.id) ? [entry.id] : [])), ), From e677fd2bd158526bb5febe5e2d137207a2ef7c71 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 2 Nov 2026 04:14:51 +0000 Subject: [PATCH 2/3] fix(web): steered turns' fold rows each show their own time and edits Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 23 +++++++----- .../components/chat/MessagesTimeline.logic.ts | 36 +++++++++++-------- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 2c10d1b40081d..9548bafa27ae7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1417,7 +1417,7 @@ describe("deriveMessagesTimelineRows", () => { }, ); - it("splits a settled turn's work at each steer and keeps the total time on top", () => { + it("splits a settled turn's work at each steer, each row with its own time and edits", () => { const runId = RunId.make("steered-run"); const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); const message = ( @@ -1440,7 +1440,12 @@ describe("deriveMessagesTimelineRows", () => { streaming: false, }, }); - const work = (id: string, second: number): TimelineEntry => ({ + const work = ( + id: string, + second: number, + additions: number, + deletions: number, + ): TimelineEntry => ({ kind: "work", id, createdAt: time(second), @@ -1461,8 +1466,8 @@ describe("deriveMessagesTimelineRows", () => { status: "completed", parentItemId: null, type: "file_change", - additions: 40, - deletions: 0, + additions, + deletions, }, } as never, }, @@ -1471,9 +1476,9 @@ describe("deriveMessagesTimelineRows", () => { deriveMessagesTimelineRows({ timelineEntries: [ message("prompt", 0, "user", "turn_start"), - work("work-before-steer", 4), + work("work-before-steer", 4, 12, 1), message("steer", 10, "user", "steer"), - work("work-after-steer", 15), + work("work-after-steer", 15, 40, 0), message("final", 30, "assistant"), ], latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, @@ -1503,13 +1508,13 @@ describe("deriveMessagesTimelineRows", () => { "final", ]); expect(collapsed[1]).toMatchObject({ - label: "Worked for 30s", - diff: { additions: 30, deletions: 2 }, + label: "Worked for 10s", + diff: { additions: 12, deletions: 1 }, }); expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10), - diff: null, + diff: { additions: 40, deletions: 0 }, }); expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 03cb3a9565691..ad45db924f131 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1130,6 +1130,12 @@ function sumFileChangeLineStat(entries: Iterable): DiffLineStat | return reported ? { additions, deletions } : null; } +function sumTimelineFileChangeLineStat(entries: ReadonlyArray): DiffLineStat | null { + return sumFileChangeLineStat( + entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])), + ); +} + function sumCheckpointLineStat(summary: TurnDiffSummary): DiffLineStat | null { const { count, additions, deletions } = checkpointFileTotals(summary); return count === 0 ? null : { additions, deletions }; @@ -1345,11 +1351,15 @@ function deriveTurnFolds(input: { // A turn cut short by a steer leaves trailing work entries behind its // terminal message — take whichever ended last. const lastEntryEnd = timelineEntryEnd(lastEntry); - const elapsedMs = - input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt + const turnStart = group.startBoundary ?? firstEntry.createdAt; + const firstSteer = group.steerSegments[0]; + const firstSegment = group.entries.slice(0, firstSteer?.startIndex); + const elapsedMs = firstSteer + ? computeElapsedMs(turnStart, firstSteer.steer.createdAt) + : input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) : computeElapsedMs( - group.startBoundary ?? firstEntry.createdAt, + turnStart, maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? lastEntryEnd, ); @@ -1377,25 +1387,21 @@ function deriveTurnFolds(input: { : duration ? `Worked for ${duration}` : "Worked"; - // The checkpoint diff is what the changed-files card shows; provider - // counts are the fallback for turns without a checkpoint. - const checkpoint = readyCheckpointByRunId.get(runId); + // An unsteered turn shows the checkpoint diff, as the changed-files card + // does. A steered turn's rows each count their own provider edits. + const checkpoint = firstSteer ? undefined : readyCheckpointByRunId.get(runId); const diff = (checkpoint ? sumCheckpointLineStat(checkpoint) : null) ?? - sumFileChangeLineStat( - group.entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])), - ); + sumTimelineFileChangeLineStat(firstSegment); - // The top row keeps the whole turn's time, steers included. Work after a - // steer folds under its own row below that steer, keyed apart from the run - // so each segment opens on its own. + // Work after a steer folds under its own row below that steer, keyed apart + // from the run so each segment opens on its own. const segmentHiddenIds = (entries: ReadonlyArray) => new Set(entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : []))); - const firstSegment = group.entries.slice(0, group.steerSegments[0]?.startIndex); foldsByAnchorEntryId.set(group.anchorEntryId, { runId, anchorEntryId: group.anchorEntryId, - createdAt: group.startBoundary ?? firstEntry.createdAt, + createdAt: turnStart, hiddenEntryIds: segmentHiddenIds(firstSegment), entryIds: new Set(firstSegment.map((entry) => entry.id)), label, @@ -1423,7 +1429,7 @@ function deriveTurnFolds(input: { label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued", // The turn summary owns its terminal message; each segment keeps its own disclosure. ending: failed ? ending : "worked", - diff: null, + diff: sumTimelineFileChangeLineStat(entries), endingEntryIds: new Set( entries.flatMap((entry) => (endingEntryIds.has(entry.id) ? [entry.id] : [])), ), From 599b6323463d78948aa6b7156f6f2968a358d8d4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Mon, 2 Nov 2026 04:34:18 +0000 Subject: [PATCH 3/3] fix(web): a steered turn's ending shows on the row where it happened A stopped, failed or limited steered turn put its ending on the top row, which now only spans the work before the first steer. The last segment row that has work now carries the ending and its message. Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 25 +++++- .../components/chat/MessagesTimeline.logic.ts | 88 +++++++++++-------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 9548bafa27ae7..b849d97920dde 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1525,6 +1525,27 @@ describe("deriveMessagesTimelineRows", () => { "work-after-steer", "final", ]); + + const stopped = deriveMessagesTimelineRows({ + timelineEntries: [ + message("prompt", 0, "user", "turn_start"), + work("work-before-steer", 4, 12, 1), + message("steer", 10, "user", "steer"), + work("work-after-steer", 15, 40, 0), + message("final", 30, "assistant"), + ], + latestRun: { runId, status: "interrupted", startedAt: time(0), completedAt: time(30) }, + isWorking: false, + expandedRunIds: new Set(), + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(stopped.find((row) => row.id === `turn-fold:${runId}`)).toMatchObject({ + ending: "worked", + }); + expect(stopped.find((row) => row.id === `turn-fold:${segmentKey}`)).toMatchObject({ + ending: "stopped", + }); }); it("stacks back-to-back steers as one burst with a single steer label", () => { @@ -3363,8 +3384,8 @@ describe("v2 run and attempt history", () => { ], } satisfies Parameters[0]; const steeredRows = deriveMessagesTimelineRows(steeredInput); - expect(steeredRows[0]).toMatchObject({ ending: "stopped", foldable: true }); - expect(steeredRows.at(-1)).toMatchObject({ ending: "worked", foldable: false }); + expect(steeredRows[0]).toMatchObject({ ending: "worked", foldable: true }); + expect(steeredRows.at(-1)).toMatchObject({ ending: "stopped", foldable: false }); expect( deriveMessagesTimelineRows({ ...steeredInput, expandedRunIds: new Set([runId]) }).map( (row) => row.id, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ad45db924f131..d089652800156 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1354,16 +1354,27 @@ function deriveTurnFolds(input: { const turnStart = group.startBoundary ?? firstEntry.createdAt; const firstSteer = group.steerSegments[0]; const firstSegment = group.entries.slice(0, firstSteer?.startIndex); - const elapsedMs = firstSteer - ? computeElapsedMs(turnStart, firstSteer.steer.createdAt) - : input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt - ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) - : computeElapsedMs( - turnStart, - maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? - lastEntryEnd, - ); - const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; + const segmentEntries = (index: number) => + group.entries.slice( + group.steerSegments[index]!.startIndex, + group.steerSegments[index + 1]?.startIndex, + ); + // The last steer segment with its own row says how the turn ended. + const endingSegmentIndex = group.steerSegments.findLastIndex((_, index) => + hidesNonCompactionWork(segmentEntries(index)), + ); + const elapsedMs = + firstSteer && endingSegmentIndex >= 0 + ? computeElapsedMs(turnStart, firstSteer.steer.createdAt) + : input.latestRun?.runId === runId && + input.latestRun.startedAt && + input.latestRun.completedAt + ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) + : computeElapsedMs( + turnStart, + maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? + lastEntryEnd, + ); const ending: TurnFoldEnding = failed ? usageLimitedRunIds.has(runId) ? "limited" @@ -1371,22 +1382,8 @@ function deriveTurnFolds(input: { : isLatestInterruptedTurn || interruptedRunIds.has(runId) ? "stopped" : "worked"; - const label = - ending === "stopped" - ? duration - ? `Stopped at ${duration}` - : "Stopped" - : ending === "failed" - ? duration - ? `Failed after ${duration}` - : "Failed" - : ending === "limited" - ? duration - ? `Limited at ${duration}` - : "Limited" - : duration - ? `Worked for ${duration}` - : "Worked"; + const topEnding: TurnFoldEnding = endingSegmentIndex >= 0 ? "worked" : ending; + const label = turnFoldLabel(topEnding, elapsedMs, "Worked"); // An unsteered turn shows the checkpoint diff, as the changed-files card // does. A steered turn's rows each count their own provider edits. const checkpoint = firstSteer ? undefined : readyCheckpointByRunId.get(runId); @@ -1405,14 +1402,14 @@ function deriveTurnFolds(input: { hiddenEntryIds: segmentHiddenIds(firstSegment), entryIds: new Set(firstSegment.map((entry) => entry.id)), label, - ending, + ending: topEnding, diff, endingEntryIds, - failureMessage, - noticeMessage, + failureMessage: topEnding === ending ? failureMessage : null, + noticeMessage: topEnding === ending ? noticeMessage : null, }); - for (const [index, { startIndex, steer }] of group.steerSegments.entries()) { - const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex); + for (const [index, { steer }] of group.steerSegments.entries()) { + const entries = segmentEntries(index); const anchorEntry = entries[0]; if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue; const segmentMs = computeElapsedMs( @@ -1420,27 +1417,46 @@ function deriveTurnFolds(input: { entries.map(timelineEntryEnd).reduce(maxIsoTimestamp, null) ?? steer.createdAt, ); + const ownsEnding = index === endingSegmentIndex; + const segmentEnding: TurnFoldEnding = ownsEnding ? ending : "worked"; foldsByAnchorEntryId.set(anchorEntry.id, { runId: RunId.make(`${runId}:steer:${steer.id}`), anchorEntryId: anchorEntry.id, createdAt: steer.createdAt, hiddenEntryIds: segmentHiddenIds(entries), entryIds: new Set(entries.map((entry) => entry.id)), - label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued", - // The turn summary owns its terminal message; each segment keeps its own disclosure. - ending: failed ? ending : "worked", + label: turnFoldLabel(segmentEnding, segmentMs, "Continued"), + ending: segmentEnding, diff: sumTimelineFileChangeLineStat(entries), endingEntryIds: new Set( entries.flatMap((entry) => (endingEntryIds.has(entry.id) ? [entry.id] : [])), ), - failureMessage: null, - noticeMessage: null, + failureMessage: ownsEnding ? failureMessage : null, + noticeMessage: ownsEnding ? noticeMessage : null, }); } } return foldsByAnchorEntryId; } +function turnFoldLabel( + ending: TurnFoldEnding, + elapsedMs: number | null, + workedLabel: "Worked" | "Continued", +): string { + const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; + switch (ending) { + case "stopped": + return duration ? `Stopped at ${duration}` : "Stopped"; + case "failed": + return duration ? `Failed after ${duration}` : "Failed"; + case "limited": + return duration ? `Limited at ${duration}` : "Limited"; + case "worked": + return duration ? `${workedLabel} for ${duration}` : workedLabel; + } +} + function attachTrailingToolGroupsToAssistant( rows: ReadonlyArray, ): MessagesTimelineRow[] {