diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b849d97920dde..2d28db614755a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1338,7 +1338,7 @@ describe("deriveMessagesTimelineRows", () => { }); it.each(["steer", "promoted_queued_to_steer"] as const)( - "keeps the duration header at the initiating prompt when %s arrives before output", + "puts the fold after a %s that arrives before output", (inputIntent) => { const runId = RunId.make("steered-run"); const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); @@ -1400,16 +1400,14 @@ describe("deriveMessagesTimelineRows", () => { turnDiffSummaries: [], supportsConversationRollback: false, }); - // The total-duration header stays at the initiating prompt; steers - // divide the work segments that follow it. expect(rows.slice(0, 3).map((row) => row.id)).toEqual( isWorking ? ["initial-prompt", "working-indicator-row", "steer"] - : ["initial-prompt", `turn-fold:${runId}`, "steer"], + : ["initial-prompt", "steer", `turn-fold:${runId}`], ); - const header = rows[1]; + const header = rows.find((row) => row.kind === "working" || row.kind === "turn-fold"); expect(header?.createdAt).toBe(time(0)); - if (!isWorking) expect(header).toMatchObject({ label: "Worked for 20s", expanded }); + if (!isWorking) expect(header).toMatchObject({ expanded }); expect(rows.some((row) => row.id === "final")).toBe(true); expect(rows.some((row) => row.id === "work")).toBe(isWorking || expanded); } @@ -1417,7 +1415,7 @@ describe("deriveMessagesTimelineRows", () => { }, ); - it("splits a settled turn's work at each steer, each row with its own time and edits", () => { + it("folds a steered turn once, after its last steer, and keeps replies between steers", () => { const runId = RunId.make("steered-run"); const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); const message = ( @@ -1440,12 +1438,7 @@ describe("deriveMessagesTimelineRows", () => { streaming: false, }, }); - const work = ( - id: string, - second: number, - additions: number, - deletions: number, - ): TimelineEntry => ({ + const edit = (id: string, second: number, additions: number): TimelineEntry => ({ kind: "work", id, createdAt: time(second), @@ -1453,35 +1446,35 @@ describe("deriveMessagesTimelineRows", () => { id, createdAt: time(second), runId, - label: "Edited a.ts", + label: "Edited file", tone: "tool", + itemType: "file_change", projectedItem: { position: 0, visibility: "local", sourceThreadId: ThreadId.make("thread"), sourceItemId: id, - item: { - id, - runId, - status: "completed", - parentItemId: null, - type: "file_change", - additions, - deletions, - }, + item: { id, runId, status: "completed", type: "file_change", additions, deletions: 1 }, } as never, }, }); - const rows = (expandedRunIds: ReadonlySet) => + const rows = ( + status: "completed" | "interrupted", + expandedRunIds: ReadonlySet = new Set(), + ) => 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), + edit("edit-before-steers", 4, 10), + message("first-steer", 10, "user", "steer"), + message("second-steer", 12, "user", "steer"), + edit("edit-before-reply", 15, 20), + message("reply", 20, "assistant"), + message("third-steer", 25, "user", "steer"), + edit("edit-after-steers", 28, 30), message("final", 30, "assistant"), ], - latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, + latestRun: { runId, status, startedAt: time(0), completedAt: time(30) }, isWorking: false, expandedRunIds, turnDiffSummaries: [ @@ -1498,54 +1491,37 @@ describe("deriveMessagesTimelineRows", () => { supportsConversationRollback: false, }); - const segmentKey = RunId.make(`${runId}:steer:steer`); - const collapsed = rows(new Set()); + const collapsed = rows("completed"); expect(collapsed.map((row) => row.id)).toEqual([ "prompt", + "first-steer", + "second-steer", + "reply", + "third-steer", `turn-fold:${runId}`, - "steer", - `turn-fold:${segmentKey}`, "final", ]); - 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(collapsed[5]).toMatchObject({ + createdAt: time(0), + ending: "worked", + diff: { additions: 30, deletions: 2 }, }); + expect(rows("interrupted").filter((row) => row.kind === "turn-fold")).toEqual([ + expect.objectContaining({ ending: "stopped" }), + ]); - expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ + const expanded = rows("completed", new Set([runId])); + expect(expanded.map((row) => row.id)).toEqual([ "prompt", + "first-steer", + "second-steer", + "reply", + "third-steer", `turn-fold:${runId}`, - "steer", - `turn-fold:${segmentKey}`, - "work-after-steer", + "work-toggle:edit-before-steers", "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", - }); + expect(expanded[6]).toMatchObject({ hiddenCount: 3 }); }); it("stacks back-to-back steers as one burst with a single steer label", () => { @@ -1605,10 +1581,9 @@ describe("deriveMessagesTimelineRows", () => { ).result; const byId = new Map(rows.map((row) => [row.id, row])); - expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); expect(byId.get("first-steer")).toMatchObject({ groupedWithNext: true, - groupedWithPrevious: false, + groupedWithPrevious: true, continuesSteer: false, }); expect(byId.get("second-steer")).toMatchObject({ @@ -3384,13 +3359,15 @@ describe("v2 run and attempt history", () => { ], } satisfies Parameters[0]; const steeredRows = deriveMessagesTimelineRows(steeredInput); - expect(steeredRows[0]).toMatchObject({ ending: "worked", foldable: true }); - expect(steeredRows.at(-1)).toMatchObject({ ending: "stopped", foldable: false }); + expect(steeredRows).toEqual([ + expect.objectContaining({ id: "steer" }), + expect.objectContaining({ id: "turn-fold:turn-1", ending: "stopped", foldable: true }), + ]); expect( deriveMessagesTimelineRows({ ...steeredInput, expandedRunIds: new Set([runId]) }).map( (row) => row.id, ), - ).toContain("work-entry"); + ).toEqual(["steer", "turn-fold:turn-1", "work-entry"]); // A stop before any work still states the stop, with nothing to open. const noWorkRows = deriveMessagesTimelineRows({ ...input, @@ -3936,7 +3913,7 @@ describe("linked timeline resources", () => { ]); }); - it("shows a created chat after a steer only when that steer's segment is open", () => { + it("folds a created chat after a steer under the turn's one fold", () => { const message = (id: string, role: "user" | "assistant", second: number) => ({ kind: "message" as const, id, @@ -3959,18 +3936,17 @@ describe("linked timeline resources", () => { message("after-steer", "assistant", 3), message("final", "assistant", 4), ]; - const segmentKey = RunId.make(`${runId}:steer:steer`); const rowIds = (expanded: RunId[]) => deriveMessagesTimelineRows({ ...common, timelineEntries, expandedRunIds: new Set(expanded), }).map((row) => row.id); - expect(rowIds([runId])).not.toContain("created"); - expect(rowIds([segmentKey])).toEqual([ - `turn-fold:${runId}`, + expect(rowIds([])).not.toContain("created"); + expect(rowIds([runId])).toEqual([ + "intro", "steer", - `turn-fold:${segmentKey}`, + `turn-fold:${runId}`, "created", "after-steer", "final", @@ -4408,7 +4384,7 @@ describe("working window", () => { (row): row is Extract => row.kind === "working", ); - it("moves the running turn's work under the bar and steers above it", () => { + it("moves the running turn's work under the bar and keeps steers and replies above it", () => { const rows = run([ prompt("prompt", 0), work("edit", 2, { @@ -4420,6 +4396,20 @@ describe("working window", () => { deletions: 22, }) as never, }), + { + id: "reply", + kind: "message", + createdAt: time(2), + message: { + id: MessageId.make("reply"), + role: "assistant", + text: "reply", + runId, + createdAt: time(2), + updatedAt: time(2), + streaming: false, + }, + }, prompt("steer", 3, "steer"), work("command", 4, { label: "Running bun typecheck", @@ -4427,7 +4417,13 @@ describe("working window", () => { toolLifecycleStatus: "inProgress", }), ]); - expect(rows.map((row) => row.id)).toEqual(["prompt", "steer", "working-indicator-row"]); + expect(rows.map((row) => row.id)).toEqual([ + "prompt", + "reply", + "steer", + "working-indicator-row", + ]); + expect(rows[1]).toMatchObject({ showAssistantTimestamp: false }); const window = workingRow(rows)?.window; expect(window?.items.map((item) => item.id)).toEqual(["edit", "command"]); expect(window?.diff).toEqual({ additions: 84, deletions: 22 }); @@ -4436,7 +4432,7 @@ describe("working window", () => { expect(window?.historyOpen).toBe(false); }); - it("keeps steers between work segments once the turn settles", () => { + it("puts the settled fold where the bar was, after the steer", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ prompt("prompt", 0), @@ -4465,8 +4461,8 @@ describe("working window", () => { }); expect(rows.map((row) => row.id)).toEqual([ "prompt", - `turn-fold:${runId}`, "steer", + `turn-fold:${runId}`, "answer", "next", ]); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index d089652800156..ac178efd9c37a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -636,8 +636,6 @@ type MessagesTimelineRowContent = groupedWithPrevious?: boolean; /** Set when the message above is a steer too, so the label shows once. */ continuesSteer?: boolean; - /** Milliseconds from the turn's start to a steer landing in it. */ - steerOffsetMs?: number | undefined; /** A prompt that opens with a provider command. A standalone one renders as a work row. */ command?: TimelineSlashCommand | undefined; } @@ -759,7 +757,7 @@ interface TurnFold { anchorEntryId: string; createdAt: string; hiddenEntryIds: ReadonlySet; - /** Every entry in the segment, including the cards a fold never hides. */ + /** Every entry in the turn, including the cards a fold never hides. */ entryIds: ReadonlySet; label: string; ending: TurnFoldEnding; @@ -966,6 +964,26 @@ function timelineEntryStartsResponse(entry: TimelineEntry): boolean { ); } +function steerReplyEntryIds(entries: ReadonlyArray): ReadonlySet { + const ids = new Set(); + let lastReply: string | null = null; + for (const entry of entries) { + if (timelineEntryStartsResponse(entry)) { + lastReply = null; + } else if (timelineEntryIsSteer(entry)) { + if (lastReply !== null) ids.add(lastReply); + lastReply = null; + } else if ( + entry.kind === "message" && + entry.message.role === "assistant" && + entry.attempt?.status !== "superseded" + ) { + lastReply = entry.id; + } + } + return ids; +} + function timelineEntryEnd(entry: TimelineEntry): string { return entry.kind === "message" ? entry.message.updatedAt : entry.createdAt; } @@ -1180,11 +1198,6 @@ function deriveTurnFolds(input: { // Entry timestamps omit startup and can collapse steered turns to an instant. startBoundary: string | null; anchorEntryId: string; - /** - * Each steer after the turn has produced output starts a new segment at - * this index into `entries`, so the steer sits between the work it split. - */ - steerSegments: Array<{ startIndex: number; steer: TimelineEntry }>; } const groupsByRunId = new Map(); const runlessFailedKeys = new Set(); @@ -1207,12 +1220,10 @@ function deriveTurnFolds(input: { if (entry.kind === "message" && timelineEntryIsSteer(entry)) { const steerRunId = entry.message.runId ?? responseKey; const group = steerRunId === null ? undefined : groupsByRunId.get(steerRunId); - if (group !== undefined && group.entries.length > 0) { - // Back-to-back steers split once, after the last of them. - if (group.steerSegments.at(-1)?.startIndex === group.entries.length) { - group.steerSegments.pop(); - } - group.steerSegments.push({ startIndex: group.entries.length, steer: entry }); + const nextEntry = input.timelineEntries[index + 1]; + if (nextEntry !== undefined) { + if (group !== undefined) group.anchorEntryId = nextEntry.id; + else if (pendingBoundary !== null) pendingBoundary.anchorEntryId = nextEntry.id; } continue; } @@ -1244,7 +1255,6 @@ function deriveTurnFolds(input: { hasStreamingMessage: false, startBoundary: pendingBoundary?.createdAt ?? null, anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id, - steerSegments: [], }; pendingBoundary = null; groupsByRunId.set(runId, group); @@ -1266,6 +1276,7 @@ function deriveTurnFolds(input: { readyCheckpointByRunId.set(summary.runId, summary); } const foldsByAnchorEntryId = new Map(); + const replyIds = steerReplyEntryIds(input.timelineEntries); for (const [runId, group] of groupsByRunId) { if (input.unfoldedRunIds.has(runId) || (input.runlessWorkActive && runId === runlessKey)) { continue; @@ -1288,7 +1299,7 @@ function deriveTurnFolds(input: { ? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id) : group.entries.length; for (const [index, entry] of group.entries.entries()) { - if (entry.id === group.terminalEntry?.id) { + if (entry.id === group.terminalEntry?.id || replyIds.has(entry.id)) { continue; } if (failed) { @@ -1333,10 +1344,11 @@ function deriveTurnFolds(input: { // A stopped fold says so itself; its interrupt divider never shows. if (isInterruptResult && !failed) endingEntryIds.add(entry.id); } - const hidesNonCompactionWork = (entries: ReadonlyArray) => - entries.some((entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry)); + const hidesNonCompactionWork = group.entries.some( + (entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry), + ); // A reply still owns its duration bar when there was no work to expand. - if (!hidesNonCompactionWork(group.entries) && group.terminalEntry === null) { + if (!hidesNonCompactionWork && group.terminalEntry === null) { continue; } @@ -1351,30 +1363,14 @@ 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 = - 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, - ); + 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 ending: TurnFoldEnding = failed ? usageLimitedRunIds.has(runId) ? "limited" @@ -1382,68 +1378,34 @@ function deriveTurnFolds(input: { : isLatestInterruptedTurn || interruptedRunIds.has(runId) ? "stopped" : "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); + const label = turnFoldLabel(ending, elapsedMs); + // 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 diff = (checkpoint ? sumCheckpointLineStat(checkpoint) : null) ?? - sumTimelineFileChangeLineStat(firstSegment); + sumTimelineFileChangeLineStat(group.entries); - // 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] : []))); foldsByAnchorEntryId.set(group.anchorEntryId, { runId, anchorEntryId: group.anchorEntryId, - createdAt: turnStart, - hiddenEntryIds: segmentHiddenIds(firstSegment), - entryIds: new Set(firstSegment.map((entry) => entry.id)), + createdAt: group.startBoundary ?? firstEntry.createdAt, + hiddenEntryIds, + entryIds: new Set( + group.entries.flatMap((entry) => (replyIds.has(entry.id) ? [] : [entry.id])), + ), label, - ending: topEnding, + ending, diff, endingEntryIds, - failureMessage: topEnding === ending ? failureMessage : null, - noticeMessage: topEnding === ending ? noticeMessage : null, + failureMessage, + noticeMessage, }); - 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( - steer.createdAt, - 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: turnFoldLabel(segmentEnding, segmentMs, "Continued"), - ending: segmentEnding, - diff: sumTimelineFileChangeLineStat(entries), - endingEntryIds: new Set( - entries.flatMap((entry) => (endingEntryIds.has(entry.id) ? [entry.id] : [])), - ), - failureMessage: ownsEnding ? failureMessage : null, - noticeMessage: ownsEnding ? noticeMessage : null, - }); - } } return foldsByAnchorEntryId; } -function turnFoldLabel( - ending: TurnFoldEnding, - elapsedMs: number | null, - workedLabel: "Worked" | "Continued", -): string { +function turnFoldLabel(ending: TurnFoldEnding, elapsedMs: number | null): string { const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; switch (ending) { case "stopped": @@ -1453,10 +1415,38 @@ function turnFoldLabel( case "limited": return duration ? `Limited at ${duration}` : "Limited"; case "worked": - return duration ? `${workedLabel} for ${duration}` : workedLabel; + return duration ? `Worked for ${duration}` : "Worked"; } } +function gatherOpenFoldWork( + entries: ReadonlyArray, + folds: Iterable, + isOpen: (fold: TurnFold) => boolean, +) { + const gatheredByAnchorId = new Map(); + const foldsByAnchorEntryId = new Map(); + for (const fold of folds) { + const anchorIndex = isOpen(fold) + ? entries.findIndex((entry) => entry.id === fold.anchorEntryId) + : -1; + const gathered = + anchorIndex > 0 + ? entries.slice(0, anchorIndex).filter((entry) => fold.hiddenEntryIds.has(entry.id)) + : []; + if (gathered.length > 0) gatheredByAnchorId.set(fold.anchorEntryId, gathered); + foldsByAnchorEntryId.set(gathered[0]?.id ?? fold.anchorEntryId, fold); + } + if (gatheredByAnchorId.size === 0) return { entries, foldsByAnchorEntryId }; + const gatheredIds = new Set([...gatheredByAnchorId.values()].flat().map((entry) => entry.id)); + return { + entries: entries.flatMap((entry) => + gatheredIds.has(entry.id) ? [] : [...(gatheredByAnchorId.get(entry.id) ?? []), entry], + ), + foldsByAnchorEntryId, + }; +} + function attachTrailingToolGroupsToAssistant( rows: ReadonlyArray, ): MessagesTimelineRow[] { @@ -1678,10 +1668,6 @@ export function deriveMessagesTimelineRows(input: { }) : new Map(); const nextRows: MessagesTimelineRow[] = []; - const steerOffsetStartByRunId = deriveSteerOffsetStartByRunId( - timelineEntries, - input.latestRun ?? null, - ); const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries); const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null); const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds); @@ -1711,7 +1697,7 @@ export function deriveMessagesTimelineRows(input: { isWorking: input.isWorking, }); const runlessWorkActive = input.isWorking && input.runlessWorkActive === true; - const foldsByAnchorEntryId = deriveTurnFolds({ + const turnFolds = deriveTurnFolds({ timelineEntries: timelineEntries, terminalAssistantMessageIds, latestRun: input.latestRun ?? null, @@ -1725,6 +1711,12 @@ export function deriveMessagesTimelineRows(input: { const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry])); const turnFoldExpanded = (fold: TurnFold) => (input.expandedRunIds?.has(fold.runId) ?? false) !== turnFoldStartsOpen(fold.ending); + const { entries: rowEntries, foldsByAnchorEntryId } = gatherOpenFoldWork( + timelineEntries, + turnFolds.values(), + turnFoldExpanded, + ); + const steerReplyIds = steerReplyEntryIds(timelineEntries); const collapsedEntryIds = new Set(); const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { @@ -1982,8 +1974,8 @@ export function deriveMessagesTimelineRows(input: { }; }; - for (let index = 0; index < timelineEntries.length; index += 1) { - const timelineEntry = timelineEntries[index]; + for (let index = 0; index < rowEntries.length; index += 1) { + const timelineEntry = rowEntries[index]; if (!timelineEntry) { continue; } @@ -2125,8 +2117,8 @@ export function deriveMessagesTimelineRows(input: { } const groupedEntries = [timelineEntry.entry]; let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; + while (cursor < rowEntries.length) { + const nextEntry = rowEntries[cursor]; if ( !nextEntry || nextEntry.kind !== "work" || @@ -2359,7 +2351,8 @@ export function deriveMessagesTimelineRows(input: { showAssistantTimestamp: isTerminalAssistantMessage && assistantResponseStillInProgress && - !timelineEntry.message.streaming, + !timelineEntry.message.streaming && + !steerReplyIds.has(timelineEntry.id), showAssistantCopyButton: showAssistantMeta, assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: @@ -2370,7 +2363,6 @@ export function deriveMessagesTimelineRows(input: { timelineEntry.message.role === "user" ? revertTurnCountByUserMessageId.get(timelineEntry.message.id) : undefined, - steerOffsetMs: steerOffsetMs(timelineEntry.message, steerOffsetStartByRunId), }); } @@ -2505,42 +2497,6 @@ function userMessageIsSteer(message: ChatMessage): boolean { return message.inputIntent === "steer" || message.inputIntent === "promoted_queued_to_steer"; } -/** - * Where each run's clock starts for steer offsets: the latest run's own start - * when known, otherwise the prompt that opened the run. - */ -function deriveSteerOffsetStartByRunId( - timelineEntries: ReadonlyArray, - latestRun: TimelineLatestRun | null, -): ReadonlyMap { - const starts = new Map(); - for (const entry of timelineEntries) { - if (entry.kind !== "message" || entry.message.role !== "user") continue; - const runId = entry.message.runId; - if (runId == null || starts.has(runId) || userMessageIsSteer(entry.message)) continue; - const ms = Date.parse(entry.message.createdAt); - if (Number.isFinite(ms)) starts.set(runId, ms); - } - if (latestRun?.startedAt) { - const ms = Date.parse(latestRun.startedAt); - if (Number.isFinite(ms)) starts.set(latestRun.runId, ms); - } - return starts; -} - -function steerOffsetMs( - message: ChatMessage, - startByRunId: ReadonlyMap, -): number | undefined { - if (message.role !== "user" || !userMessageIsSteer(message) || message.runId == null) { - return undefined; - } - const start = startByRunId.get(message.runId); - const at = Date.parse(message.createdAt); - if (start === undefined || !Number.isFinite(at) || at < start) return undefined; - return at - start; -} - function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean { return ( row !== undefined && @@ -2742,9 +2698,9 @@ function rowIsReplacedByWindow(row: MessagesTimelineRow, ids: ReadonlySet, @@ -2778,6 +2734,7 @@ export function applyWorkingWindow( isWorking: true, }); const boundaryIndex = lastResponseBoundaryIndex(entries); + const replyIds = steerReplyEntryIds(entries); let key = entries[boundaryIndex]?.id ?? "working"; const items: WorkingWindowItem[] = []; // A standalone `/compact` row already shows its run's compaction. @@ -2799,7 +2756,7 @@ export function applyWorkingWindow( key = entry.id; continue; } - if (entry.attempt?.status === "superseded") 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. @@ -2867,16 +2824,14 @@ export function applyWorkingWindow( } const ids = collectWindowIds(items); - const before: MessagesTimelineRow[] = []; - const after: MessagesTimelineRow[] = []; - for (const [index, row] of rows.entries()) { - if (index <= workingIndex) continue; - if (row.kind === "message" && row.message.role === "user") { - before.push(row); - } else if (!rowIsReplacedByWindow(row, ids)) { - after.push(row); - } - } + const windowStartIndex = Math.max( + workingIndex, + rows.findLastIndex((row) => row.kind === "message" && row.message.role === "user"), + ); + const keptRows = (start: number, end?: number) => + rows.slice(start, end).filter((row) => !rowIsReplacedByWindow(row, ids)); + const before = keptRows(workingIndex + 1, windowStartIndex + 1); + const after = keptRows(windowStartIndex + 1); const window: WorkingWindow = { key, items, @@ -3076,8 +3031,8 @@ interface MessagesTimelineRowsProjection { } /** - * Runless (imported V1) turns and work after a steer fold under synthetic - * keys, so the entry's own run id is not enough to open them. + * Runless (imported V1) turns fold under synthetic keys, so the entry's own + * run id is not enough to open them. */ export type TimelineTurnFoldKey = Pick; @@ -3423,7 +3378,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.groupedWithNext === bm.groupedWithNext && a.groupedWithPrevious === bm.groupedWithPrevious && a.continuesSteer === bm.continuesSteer && - a.steerOffsetMs === bm.steerOffsetMs && shallow(a.command, bm.command) ); } diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index d5f45744cf65c..465cdc7c7f5f7 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -296,7 +296,7 @@ function AutomationMessageRow({ row }: { row: Extract