diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7cd4adb993146..b849d97920dde 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,25 +1440,61 @@ 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), - 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, + deletions, + }, + } as never, + }, }); const rows = (expandedRunIds: ReadonlySet) => 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) }, 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 +1507,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 10s", + diff: { additions: 12, deletions: 1 }, + }); + expect(collapsed[3]).toMatchObject({ + label: "Continued for 20s", + createdAt: time(10), + diff: { additions: 40, deletions: 0 }, + }); expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ "prompt", @@ -1482,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", () => { @@ -3320,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 7d493e0ce7197..d089652800156 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,15 +1351,30 @@ 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 turnStart = group.startBoundary ?? firstEntry.createdAt; + const firstSteer = group.steerSegments[0]; + const firstSegment = group.entries.slice(0, firstSteer?.startIndex); + 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 = - input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt - ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) - : computeElapsedMs( - group.startBoundary ?? firstEntry.createdAt, - maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? - lastEntryEnd, - ); - const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; + 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" @@ -1361,52 +1382,34 @@ 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"; - // 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); + 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); 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, - 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( @@ -1414,29 +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", - diff: sumFileChangeLineStat( - entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])), - ), + 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[] {