diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 4abf23d26a0e..e544a113040c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1347,6 +1347,48 @@ describe("deriveMessagesTimelineRows", () => { ]); }); + it("keeps the working header below the initiating prompt across repeated steers", () => { + const runId = RunId.make("steered-run"); + const startedAt = "2026-01-01T00:00:00Z"; + const prompt = (id: string, inputIntent: "steer" | "send") => ({ + id, + kind: "message" as const, + createdAt: startedAt, + message: { + id: MessageId.make(id), + role: "user" as const, + text: id, + runId, + ...(inputIntent === "steer" ? { inputIntent } : {}), + createdAt: startedAt, + updatedAt: startedAt, + streaming: false, + }, + }); + const timelineEntries = [prompt("initial-prompt", "send")]; + for (let index = 0; index < 3; index += 1) { + const rows = deriveMessagesTimelineRows({ + timelineEntries, + latestRun: { runId, status: "running", startedAt, completedAt: null }, + isWorking: true, + activeTurnStartedAt: startedAt, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(rows.slice(0, 2).map((row) => row.id)).toEqual([ + "initial-prompt", + "working-indicator-row", + ]); + expect(rows.filter((row) => row.kind === "working")).toEqual([ + expect.objectContaining({ createdAt: startedAt }), + ]); + expect(rows.filter((row) => row.kind === "message").map((row) => row.id)).toEqual( + timelineEntries.map((entry) => entry.id), + ); + timelineEntries.push(prompt(`steer-${index}`, "steer")); + } + }); + it("keeps the previous turn folded while a newly sent message awaits its turn", () => { // Right after send, isWorking is true but latestRun still points at the // previous, settled turn — it must stay folded through that window. diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f9de9bf11aaf..5e714fbf495e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1000,13 +1000,19 @@ export function deriveMessagesTimelineRows(input: { entry.toolLifecycleStatus === "inProgress" && entry.runId === unsettledRunId; - // The active run's header row ("Working for ...") anchors right after the - // latest user message or notification, or at the run's first owned work entry when one - // already rendered above it. + // A steer continues the current turn. Keep its elapsed-time header below + // the initiating prompt (or automatic wake), rather than moving it down. let activeTurnHeaderIndex = input.timelineEntries.length; if (input.isWorking) { - const latestResponseBoundaryIndex = lastResponseBoundaryIndex(input.timelineEntries); - activeTurnHeaderIndex = latestResponseBoundaryIndex + 1; + activeTurnHeaderIndex = + input.timelineEntries.findLastIndex( + (entry) => + (entry.kind === "message" && + entry.message.role === "user" && + entry.message.inputIntent !== "steer" && + entry.message.inputIntent !== "promoted_queued_to_steer") || + (entry.kind === "work" && entry.entry.itemType === "notification"), + ) + 1; } // Contiguous trailing work entries of the active run collapse into one live