From e94b71996cd71a241209aa2dddac321c5e400c54 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 02:26:24 -0700 Subject: [PATCH] fix(web): steers split a finished turn's work instead of stacking under it Co-Authored-By: Claude Opus 5.5 --- .../chat/MessagesTimeline.logic.test.ts | 110 ++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 102 ++++++++++++---- .../src/components/chat/MessagesTimeline.tsx | 2 +- 3 files changed, 192 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e16820a77fc7..84521d67af0c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1066,6 +1066,73 @@ describe("deriveMessagesTimelineRows", () => { }, ); + it("splits a settled turn's work at each steer and keeps the total time on top", () => { + const runId = RunId.make("steered-run"); + const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString(); + const message = ( + id: string, + second: number, + role: "user" | "assistant", + inputIntent?: "turn_start" | "steer", + ): TimelineEntry => ({ + kind: "message", + id, + createdAt: time(second), + message: { + id: MessageId.make(id), + role, + text: id, + runId, + ...(inputIntent ? { inputIntent } : {}), + createdAt: time(second), + updatedAt: time(second), + streaming: false, + }, + }); + const work = (id: string, second: number): TimelineEntry => ({ + kind: "work", + id, + createdAt: time(second), + entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" }, + }); + const rows = (expandedRunIds: ReadonlySet) => + deriveMessagesTimelineRows({ + timelineEntries: [ + message("prompt", 0, "user", "turn_start"), + work("work-before-steer", 4), + message("steer", 10, "user", "steer"), + work("work-after-steer", 15), + message("final", 30, "assistant"), + ], + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) }, + isWorking: false, + expandedRunIds, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + const segmentKey = RunId.make(`${runId}:steer:steer`); + const collapsed = rows(new Set()); + expect(collapsed.map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${runId}`, + "steer", + `turn-fold:${segmentKey}`, + "final", + ]); + expect(collapsed[1]).toMatchObject({ label: "Worked for 30s" }); + expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10) }); + + expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${runId}`, + "steer", + `turn-fold:${segmentKey}`, + "work-after-steer", + "final", + ]); + }); + it("keeps the previous turn folded while a newly sent message awaits its turn", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ @@ -3160,6 +3227,49 @@ describe("linked timeline resources", () => { "assistant-meta:final", ]); }); + + it("shows a created chat after a steer only when that steer's segment is open", () => { + const message = (id: string, role: "user" | "assistant", second: number) => ({ + kind: "message" as const, + id, + createdAt: `2026-09-08T10:00:0${second}Z`, + message: { + id: MessageId.make(id), + role, + text: id, + runId, + ...(role === "user" ? { inputIntent: "steer" as const } : {}), + streaming: false, + createdAt: `2026-09-08T10:00:0${second}Z`, + updatedAt: `2026-09-08T10:00:0${second}Z`, + }, + }); + const timelineEntries = [ + message("intro", "assistant", 0), + message("steer", "user", 1), + event("created", "thread_created"), + 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}`, + "steer", + `turn-fold:${segmentKey}`, + "created", + "after-steer", + "final", + "summary:created", + "assistant-meta:final", + ]); + }); }); it.each([true, false])( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 361bcdb62dbf..45bb7936298d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -732,6 +732,8 @@ interface TurnFold { anchorEntryId: string; createdAt: string; hiddenEntryIds: ReadonlySet; + /** Every entry in the segment, including the cards a fold never hides. */ + entryIds: ReadonlySet; label: string; } @@ -903,16 +905,30 @@ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null): return null; } +function timelineEntryIsSteer(entry: TimelineEntry): boolean { + return ( + entry.kind === "message" && + entry.message.role === "user" && + (entry.message.inputIntent === "steer" || + entry.message.inputIntent === "promoted_queued_to_steer") + ); +} + function timelineEntryStartsResponse(entry: TimelineEntry): boolean { return ( - (entry.kind === "message" && - entry.message.role === "user" && - entry.message.inputIntent !== "steer" && - entry.message.inputIntent !== "promoted_queued_to_steer") || + (entry.kind === "message" && entry.message.role === "user" && !timelineEntryIsSteer(entry)) || (entry.kind === "work" && entry.entry.itemType === "notification") ); } +function timelineEntryEnd(entry: TimelineEntry): string { + return entry.kind === "message" ? entry.message.updatedAt : entry.createdAt; +} + +function timelineEntryIsCompaction(entry: TimelineEntry): boolean { + return entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; +} + function responseBoundaryRunId(entry: TimelineEntry): RunId | null { if (entry.kind === "message") return entry.message.runId ?? null; if (entry.kind === "work") return entry.entry.runId ?? null; @@ -1019,6 +1035,11 @@ function deriveTurnFolds(input: { */ 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(); @@ -1040,6 +1061,18 @@ function deriveTurnFolds(input: { responseKey = boundaryRunId ?? runlessKey; continue; } + 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 }); + } + continue; + } const ownRunId = timelineEntryFoldRunId(entry, runlessKey); if (!ownRunId) { continue; @@ -1062,6 +1095,7 @@ function deriveTurnFolds(input: { hasStreamingMessage: false, startBoundary: pendingBoundary?.createdAt ?? null, anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id, + steerSegments: [], }; pendingBoundary = null; groupsByRunId.set(runId, group); @@ -1098,8 +1132,7 @@ function deriveTurnFolds(input: { if (entry.id === group.terminalEntry?.id) { continue; } - const isCompaction = - entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"; + const isCompaction = timelineEntryIsCompaction(entry); const isFoldableTrailingActivity = entry.kind === "work" && entry.entry.toolLifecycleStatus !== "inProgress" && @@ -1122,12 +1155,9 @@ function deriveTurnFolds(input: { if (hiddenEntryIds.size === 0) { continue; } - const hidesNonCompactionWork = group.entries.some( - (entry) => - hiddenEntryIds.has(entry.id) && - !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"), - ); - if (!hidesNonCompactionWork) { + const hidesNonCompactionWork = (entries: ReadonlyArray) => + entries.some((entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry)); + if (!hidesNonCompactionWork(group.entries)) { continue; } @@ -1141,8 +1171,7 @@ function deriveTurnFolds(input: { input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; // A turn cut short by a steer leaves trailing work entries behind its // terminal message — take whichever ended last. - const lastEntryEnd = - lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + const lastEntryEnd = timelineEntryEnd(lastEntry); const elapsedMs = input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) @@ -1160,13 +1189,38 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; + // 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. + 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, - hiddenEntryIds, + hiddenEntryIds: segmentHiddenIds(firstSegment), + entryIds: new Set(firstSegment.map((entry) => entry.id)), label, }); + for (const [index, { startIndex, steer }] of group.steerSegments.entries()) { + const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex); + const anchorEntry = entries[0]; + if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue; + const segmentMs = computeElapsedMs( + steer.createdAt, + entries.map(timelineEntryEnd).reduce(maxIsoTimestamp, null) ?? + steer.createdAt, + ); + 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", + }); + } } return foldsByAnchorEntryId; } @@ -1414,11 +1468,15 @@ export function deriveMessagesTimelineRows(input: { completeSupersededReplyEntryIds: completeSupersededReplyIds, }); const collapsedEntryIds = new Set(); + const collapsedFoldMemberIds = new Set(); for (const fold of foldsByAnchorEntryId.values()) { if (!input.expandedRunIds?.has(fold.runId)) { for (const entryId of fold.hiddenEntryIds) { collapsedEntryIds.add(entryId); } + for (const entryId of fold.entryIds) { + collapsedFoldMemberIds.add(entryId); + } } } const collapsedSupersededEntryIds = new Set(); @@ -1943,7 +2001,7 @@ export function deriveMessagesTimelineRows(input: { } const result = attachTrailingToolGroupsToAssistant( - attachCreatedThreadSummaries(nextRows, timelineEntries), + attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds), ); return result.map((row, index) => timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1]) @@ -1965,15 +2023,14 @@ function timelineRowIsWorkLog(row: MessagesTimelineRow | undefined): boolean { function attachCreatedThreadSummaries( rows: MessagesTimelineRow[], timelineEntries: ReadonlyArray, + collapsedFoldMemberIds: ReadonlySet, ): MessagesTimelineRow[] { const terminalIndexes = new Map(); - const collapsedRuns = new Set(); const createdByRun = new Map>>(); for (const [index, row] of rows.entries()) { if (row.kind === "message" && row.showAssistantMeta && row.message.runId) { terminalIndexes.set(row.message.runId, index); } - if (row.kind === "turn-fold" && !row.expanded) collapsedRuns.add(row.runId); } for (const entry of timelineEntries) { const projectedItem = @@ -1993,7 +2050,10 @@ function attachCreatedThreadSummaries( if (row.kind === "event" && row.projectedItem.item.type === "thread_created") { const runId = row.projectedItem.item.runId; const terminalIndex = runId === null ? undefined : terminalIndexes.get(runId); - if (terminalIndex !== undefined && (collapsedRuns.has(runId!) || index > terminalIndex)) + if ( + terminalIndex !== undefined && + (collapsedFoldMemberIds.has(row.id) || index > terminalIndex) + ) return []; } if (row.kind === "message" && row.showAssistantMeta && row.message.runId) { @@ -2020,8 +2080,8 @@ export interface MessagesTimelineRowsProjection { } /** - * Runless (imported V1) turns fold under a synthetic key, so the entry's own - * run id is not enough to open them. + * 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. */ export function timelineEntryTurnFoldRunId( input: Pick< diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8382c3224844..c3e2007c9aad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -540,7 +540,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ }); }, [latestRun, listIdentityKey]); - // Imported turns fold under synthetic keys rather than the entries' own run ids. + // Imported turns and post-steer work fold under synthetic keys, not the entries' run ids. const findFoldRunIds = findActive ? timelineTurnFoldRunIdsByEntryId({ timelineEntries,