diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index dd723b9522fad..997599e1eda92 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -330,19 +330,17 @@ describe("MessagesTimeline work rows", () => { .element() .closest('[data-assistant-citation-source="live-answer"]'), ).not.toBeNull(); - await screen.getByRole("button", { name: "Open work history" }).click(); await expect.element(screen.getByRole("region", { name: "Work history" })).toBeVisible(); const history = screen.getByRole("region", { name: "Work history" }).element(); - await expect.element(screen.getByText("History answer 0", { exact: true })).toBeVisible(); expect(history.clientHeight).toBeGreaterThan(0); expect(history.scrollHeight).toBeGreaterThan(history.clientHeight); expect( screen.container.querySelectorAll("[data-assistant-citation-source]").length, ).toBeLessThan(60); await act(() => { - history.scrollTop = history.scrollHeight; + history.scrollTop = 0; }); - await expect.element(screen.getByText("Live answer", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("History answer 0", { exact: true })).toBeVisible(); }); it.each(["delegated", "legacy", "missing"] as const)( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 7cd4adb993146..53592b18228c2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -110,10 +110,14 @@ describe("deriveMessagesTimelineRows", () => { toolLifecycleStatus: "inProgress", }, }; - const rows = (timelineEntries: ReadonlyArray) => + const rows = ( + timelineEntries: ReadonlyArray, + expandedRunIds: ReadonlySet = new Set(), + ) => deriveMessagesTimelineRows({ timelineEntries, runningRunId: runId, + expandedRunIds, isWorking: true, activeTurnStartedAt: at, turnDiffSummaries: [], @@ -126,9 +130,12 @@ describe("deriveMessagesTimelineRows", () => { const afterSteer = rows([first, steer]); expect(afterSteer.some((row) => row.kind === "work-live")).toBe(false); expect(afterSteer.at(-1)?.kind).toBe("thinking"); - expect(afterSteer.find((row) => row.kind === "work")).toMatchObject({ - groupedEntries: [{ id: "first-thought", toolLifecycleStatus: "completed" }], - }); + expect(afterSteer.some((row) => row.kind === "turn-fold")).toBe(true); + expect(rows([first, steer], new Set([runId])).find((row) => row.kind === "work")).toMatchObject( + { + groupedEntries: [{ id: "first-thought", toolLifecycleStatus: "completed" }], + }, + ); for (const entries of [ [first, steer, next], [first, next], @@ -1417,7 +1424,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 +1447,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 +1514,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 +1532,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", () => { @@ -2978,6 +3049,7 @@ describe("v2 run and attempt history", () => { completedAt: null, }, isWorking: false, + expandedRunIds: new Set([runId]), turnDiffSummaries: [], supportsConversationRollback: false, }; @@ -2986,6 +3058,7 @@ describe("v2 run and attempt history", () => { expect(collapsedRows.map((row) => row.id)).toEqual([ "context-handoff-entry", "initial-user-entry", + `turn-fold:${runId}`, `attempt-fold:${supersededAttemptId}`, "superseded-thread-created-entry", "steer-user-entry", @@ -3006,6 +3079,7 @@ describe("v2 run and attempt history", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "context-handoff-entry", "initial-user-entry", + `turn-fold:${runId}`, `attempt-fold:${supersededAttemptId}`, "superseded-assistant-entry", "superseded-work-entry", @@ -3109,6 +3183,7 @@ describe("v2 run and attempt history", () => { const rowsFor = ( replyCompletedSecond: number, runStatus: "running" | "completed" = "completed", + expandedRunIds: ReadonlySet = new Set(), ) => deriveMessagesTimelineRows({ timelineEntries: [ @@ -3160,6 +3235,7 @@ describe("v2 run and attempt history", () => { startedAt: at(0), completedAt: runStatus === "completed" ? at(14) : null, }, + expandedRunIds, isWorking: false, turnDiffSummaries: [], supportsConversationRollback: false, @@ -3175,7 +3251,7 @@ describe("v2 run and attempt history", () => { ); it("folds a reply the steer cut off as partial output", () => { - const rows = rowsFor(11, "running"); + const rows = rowsFor(11, "running", new Set([runId])); expect(rows.find((row) => row.kind === "attempt-fold")).toMatchObject({ attemptId: supersededAttempt.id, label: "Superseded attempt", @@ -3320,8 +3396,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, @@ -4344,32 +4420,79 @@ describe("working window", () => { (row): row is Extract => row.kind === "working", ); - it("moves the running turn's work under the bar and steers above it", () => { - const rows = run([ - prompt("prompt", 0), - work("edit", 2, { + it("folds the work before each steer and keeps only later work under the bar", () => { + const edit = (id: string, second: number, additions: number) => + work(id, second, { label: "Edited a.ts", itemType: "file_change", - projectedItem: turnItem("edit", { - type: "file_change", - additions: 84, - deletions: 22, - }) as never, - }), - prompt("steer", 3, "steer"), - work("command", 4, { - label: "Running bun typecheck", + projectedItem: turnItem(id, { type: "file_change", additions, deletions: 2 }) as never, + }); + const command = (status: "inProgress" | "completed") => + work("command", 9, { + label: "Ran bun typecheck", itemType: "command_execution", - toolLifecycleStatus: "inProgress", - }), + toolLifecycleStatus: status, + }); + const steered = [ + prompt("prompt", 0), + edit("edit-1", 2, 84), + prompt("steer-1", 3, "steer"), + edit("edit-2", 5, 10), + prompt("steer-2", 8, "steer"), + ]; + const segmentFold = `turn-fold:${RunId.make(`${runId}:steer:steer-1`)}`; + const folds = (rows: ReadonlyArray) => + rows.filter((row) => row.kind === "turn-fold"); + + const once = run(steered.slice(0, 4)); + expect(once.map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${runId}`, + "steer-1", + "working-indicator-row", ]); - expect(rows.map((row) => row.id)).toEqual(["prompt", "steer", "working-indicator-row"]); - const window = workingRow(rows)?.window; - expect(window?.items.map((item) => item.id)).toEqual(["edit", "command"]); - expect(window?.diff).toEqual({ additions: 84, deletions: 22 }); - // Sending a steer re-keys the window, which closes an open history. - expect(window?.key).toBe("steer"); - expect(window?.historyOpen).toBe(false); + expect(folds(once)[0]).toMatchObject({ diff: { additions: 84, deletions: 2 } }); + expect(workingRow(once)).toMatchObject({ + createdAt: time(3), + window: { key: "steer-1", diff: { additions: 10, deletions: 2 } }, + }); + expect(workingRow(once)?.window?.items.map((item) => item.id)).toEqual(["edit-2"]); + + const twice = run([...steered, command("inProgress")]); + expect(twice.map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${runId}`, + "steer-1", + segmentFold, + "steer-2", + "working-indicator-row", + ]); + expect(folds(twice).map((row) => row.kind === "turn-fold" && row.diff)).toEqual([ + { additions: 84, deletions: 2 }, + { additions: 10, deletions: 2 }, + ]); + expect(workingRow(twice)).toMatchObject({ + createdAt: time(8), + window: { key: "steer-2", diff: null }, + }); + expect(workingRow(twice)?.window?.items.map((item) => item.id)).toEqual(["command"]); + + const settled = deriveMessagesTimelineRows({ + timelineEntries: [...steered, command("completed")] as never, + latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(12) }, + isWorking: false, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + expect(settled.map((row) => row.id)).toEqual([ + "prompt", + `turn-fold:${runId}`, + "steer-1", + segmentFold, + "steer-2", + `turn-fold:${RunId.make(`${runId}:steer:steer-2`)}`, + ]); + expect(folds(settled).slice(0, 2)).toEqual(folds(twice)); }); it("keeps steers between work segments once the turn settles", () => { @@ -4406,6 +4529,14 @@ describe("working window", () => { "answer", "next", ]); + const live = run([ + prompt("prompt", 0), + work("command", 2, { label: "Ran sleep", itemType: "command_execution" }), + prompt("steer", 3, "steer"), + ]); + expect(live.find((row) => row.id === `turn-fold:${runId}`)).toEqual( + rows.find((row) => row.id === `turn-fold:${runId}`), + ); }); it("reports the latest failed test run until a rerun passes", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 7d493e0ce7197..a12da9481e05f 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 }; @@ -1170,7 +1176,6 @@ function deriveTurnFolds(input: { interface TurnGroup { entries: Array; terminalEntry: Extract | null; - hasStreamingMessage: boolean; // Entry timestamps omit startup and can collapse steered turns to an instant. startBoundary: string | null; anchorEntryId: string; @@ -1235,7 +1240,6 @@ function deriveTurnFolds(input: { group = { entries: [], terminalEntry: null, - hasStreamingMessage: false, startBoundary: pendingBoundary?.createdAt ?? null, anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id, steerSegments: [], @@ -1244,13 +1248,8 @@ function deriveTurnFolds(input: { groupsByRunId.set(runId, group); } group.entries.push(entry); - if (entry.kind === "message") { - if (input.terminalAssistantMessageIds.has(entry.message.id)) { - group.terminalEntry = entry; - } - if (entry.message.streaming) { - group.hasStreamingMessage = true; - } + if (entry.kind === "message" && input.terminalAssistantMessageIds.has(entry.message.id)) { + group.terminalEntry = entry; } } @@ -1261,28 +1260,38 @@ function deriveTurnFolds(input: { } const foldsByAnchorEntryId = new Map(); for (const [runId, group] of groupsByRunId) { - if (input.unfoldedRunIds.has(runId) || (input.runlessWorkActive && runId === runlessKey)) { + const live = + input.unfoldedRunIds.has(runId) || (input.runlessWorkActive && runId === runlessKey); + const liveSegmentStart = group.steerSegments.at(-1)?.startIndex; + if (live && liveSegmentStart === undefined) { continue; } - if (group.hasStreamingMessage) { + const { entries, terminalEntry, steerSegments } = live + ? { + entries: group.entries.slice(0, liveSegmentStart), + terminalEntry: null, + steerSegments: group.steerSegments.slice(0, -1), + } + : group; + if (entries.some((entry) => entry.kind === "message" && entry.message.streaming)) { continue; } - const failed = input.failedRunIds.has(runId) || runlessFailedKeys.has(runId); + const failed = !live && (input.failedRunIds.has(runId) || runlessFailedKeys.has(runId)); const prompt = input.timelineEntries[promptIndexByRunId.get(runId) ?? -1]; const compactionHasStopRow = prompt?.kind === "message" && isCompactionCommandMessage(prompt.message) && idleCompactionOf(prompt.message) !== undefined && - group.entries.some(timelineEntryIsCompaction); + entries.some(timelineEntryIsCompaction); const hiddenEntryIds = new Set(); const endingEntryIds = new Set(); let failureMessage: string | null = null; let noticeMessage: string | null = null; - const terminalEntryIndex = group.terminalEntry - ? 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) { + const terminalEntryIndex = terminalEntry + ? entries.findIndex((entry) => entry.id === terminalEntry?.id) + : entries.length; + for (const [index, entry] of entries.entries()) { + if (entry.id === terminalEntry?.id) { continue; } if (failed) { @@ -1330,12 +1339,12 @@ function deriveTurnFolds(input: { const hidesNonCompactionWork = (entries: ReadonlyArray) => 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(entries) && terminalEntry === null) { continue; } - const firstEntry = group.entries[0]; - const lastEntry = group.entries.at(-1); + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); if (!firstEntry || !lastEntry) { continue; } @@ -1345,68 +1354,64 @@ 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 - ? 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; + const turnStart = group.startBoundary ?? firstEntry.createdAt; + const firstSteer = group.steerSegments[0]; + const firstSegment = entries.slice(0, firstSteer?.startIndex); + const segmentEntries = (index: number) => + entries.slice(steerSegments[index]!.startIndex, steerSegments[index + 1]?.startIndex); + // The last steer segment with its own row says how the turn ended. + const endingSegmentIndex = live + ? -1 + : steerSegments.findLastIndex((_, index) => hidesNonCompactionWork(segmentEntries(index))); const ending: TurnFoldEnding = failed ? usageLimitedRunIds.has(runId) ? "limited" : "failed" - : isLatestInterruptedTurn || interruptedRunIds.has(runId) + : !live && (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; + // "Worked for" is the first segment's length; a stop, failure or limit on + // the top row is when in the whole turn it happened. + const elapsedMs = + firstSteer && (live || topEnding === "worked") + ? 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(terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? + lastEntryEnd, + ); + 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 steerSegments.entries()) { + const entries = segmentEntries(index); const anchorEntry = entries[0]; if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue; const segmentMs = computeElapsedMs( @@ -1414,29 +1419,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[] { @@ -2600,7 +2622,7 @@ export type WorkingWindowItem = }; export interface WorkingWindow { - /** Changes with each prompt or steer, which closes an open history. */ + /** Changes with each prompt or steer. */ readonly key: string; readonly items: ReadonlyArray; readonly diff: DiffLineStat | null; @@ -2610,7 +2632,6 @@ export interface WorkingWindow { readonly retryAttempt: number | null; /** Stop was requested and the provider has not let go yet. */ readonly stopping: boolean; - readonly historyOpen: boolean; /** A standalone /compact owns its progress row and clock. */ readonly compactCommand?: boolean; } @@ -2639,7 +2660,6 @@ function sameWorkingWindow(a: WorkingWindow | undefined, b: WorkingWindow | unde if (a === undefined || b === undefined) return false; return ( a.key === b.key && - a.historyOpen === b.historyOpen && a.stopping === b.stopping && a.compactCommand === b.compactCommand && a.retryAttempt === b.retryAttempt && @@ -2721,10 +2741,10 @@ function rowIsReplacedByWindow(row: MessagesTimelineRow, ids: ReadonlySet, @@ -2737,7 +2757,6 @@ export function applyWorkingWindow( | "runlessWorkActive" | "pendingApprovalRequestIds" >, - historyOpenKey: string | null = null, ): MessagesTimelineRow[] { const workingIndex = input.isWorking ? rows.findIndex((row) => row.kind === "working") : -1; if (workingIndex < 0) return rows as MessagesTimelineRow[]; @@ -2777,6 +2796,7 @@ export function applyWorkingWindow( const entry = entries[index]!; if (entry.kind === "message" && entry.message.role === "user") { key = entry.id; + items.length = 0; continue; } if (entry.attempt?.status === "superseded") continue; @@ -2847,16 +2867,18 @@ 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 createdAt = + workingRow.compacting === undefined && before.some((row) => row.kind === "turn-fold") + ? rows[windowStartIndex]!.createdAt + : workingRow.createdAt; const window: WorkingWindow = { key, items, @@ -2867,12 +2889,11 @@ export function applyWorkingWindow( failedTests: deriveFailedTests(items), retryAttempt: deriveRetryAttempt(items), stopping: interruptRequested && !interruptSettled, - historyOpen: historyOpenKey === key, }; return [ ...rows.slice(0, workingIndex), ...before, - { ...workingRow, window }, + { ...workingRow, createdAt, window }, ...after.map((row) => (row.continuesWorkLog ? { ...row, continuesWorkLog: false } : row)), ]; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0ef61c20de33c..cd9580a1ad1ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -506,8 +506,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const citationThreadRef = parseScopedThreadKey(routeThreadKey); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const [minimapStripMap] = useState(() => new Map()); - // The open history belongs to the prompt that opened it; sending closes it. - const [workingHistoryKey, setWorkingHistoryKey] = useState(null); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); @@ -579,14 +577,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ return next; }); }; - // Opening keeps a followed timeline at its end, so the history lands above - // the composer instead of under it. Closing behaves like any collapsing - // disclosure. - const onToggleWorkingHistory = (key: string) => { - // Either way the bar holds still: the history grows and shrinks below it. - suspendEndScrollMaintenanceForDisclosure("working-indicator-row"); - setWorkingHistoryKey((current) => (current === key ? null : key)); - }; const onToggleAttemptFold = (attemptId: RunAttemptId) => { suspendEndScrollMaintenanceForDisclosure(`attempt-fold:${attemptId}`); setExpandedAttemptIds((existing) => { @@ -688,7 +678,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ hasThreadError: threadErrorMessage !== null, isWorking, runlessWorkActive, - workingHistoryKey, windowlessCitation, activeFindEntryId, }); @@ -1157,7 +1146,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null, onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null, onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null, - onToggleWorkingHistory, onFocusComposer: onFocusComposer ?? null, onInterrupt: onInterrupt ?? null, workGroupViewState, diff --git a/apps/web/src/components/chat/TimelineRowParts.tsx b/apps/web/src/components/chat/TimelineRowParts.tsx index a793949d9a9c8..3d8e7e10bdf42 100644 --- a/apps/web/src/components/chat/TimelineRowParts.tsx +++ b/apps/web/src/components/chat/TimelineRowParts.tsx @@ -114,7 +114,7 @@ export function WorkingBarControl({ render, ...props }: Omit, "className"> & { - variant: "chip" | "history" | "question"; + variant: "chip" | "question"; tone?: "neutral" | "warning"; }) { const defaultProps = { @@ -122,9 +122,7 @@ export function WorkingBarControl({ className: cn( variant === "chip" ? "inline-flex h-[22px] shrink-0 cursor-pointer items-center gap-1.5 rounded-row px-2 text-meta whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 disabled:pointer-events-none disabled:opacity-64 [&_svg]:size-3 [&_svg]:shrink-0" - : variant === "history" - ? "flex shrink-0 cursor-pointer select-none items-center gap-2 self-stretch rounded-md text-left text-label text-muted-foreground tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 disabled:cursor-default" - : "inline-flex h-[22px] min-w-0 shrink items-center gap-1.5 rounded-row text-meta focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 text-info-foreground cursor-pointer", + : "inline-flex h-[22px] min-w-0 shrink items-center gap-1.5 rounded-row text-meta focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 text-info-foreground cursor-pointer", variant === "chip" && (tone === "warning" ? "bg-warning-foreground/10 text-warning-foreground hover:bg-warning-foreground/15" diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx index 02e49881dc05d..5e5bda1817d13 100644 --- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx +++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx @@ -14,7 +14,6 @@ import { MESSAGE_HEADING_LEVEL, SlashCommandChip, WORKING_BAR_CLASS_NAME, - WorkFoldChevron, WorkingBarControl, } from "./TimelineRowParts"; import { @@ -324,7 +323,6 @@ function ExpandedWorkGroupEntries({ const workEntryKey = (entry: TimelineWorkEntry) => entry.id; export function WorkingTimelineRow({ row }: { row: Extract }) { - const ctx = use(TimelineRowCtx); const { isCompacting, isPreparingWorktree, backgroundWorktreeSetup, pendingRequest } = use(TimelineRowActivityCtx); const window = row.window; @@ -364,22 +362,10 @@ export function WorkingTimelineRow({ row }: { row: Extract 0); return (
- ctx.onToggleWorkingHistory(window.key) : undefined} - > +
{label} : null} - {foldable ? : null} - +
{backgroundWorktreeSetup ? ( ) : null} @@ -884,12 +869,10 @@ const WorkingWindowItemView = memo(function WorkingWindowItemView({ item, itemKey, current, - historyOpen, }: { item: WorkingWindowItem; itemKey: string; current: boolean; - historyOpen: boolean; }) { const ctx = use(TimelineRowCtx); if (item.kind === "text") { @@ -921,7 +904,6 @@ const WorkingWindowItemView = memo(function WorkingWindowItemView({ ); @@ -931,15 +913,13 @@ function WorkingWindowView({ window, itemKey }: { window: WorkingWindow; itemKey const ctx = use(TimelineRowCtx); const { isCompacting, isPreparingWorktree, turnEnded, pendingRequest } = use(TimelineRowActivityCtx); - const [full, setFull] = useState(false); - const observeHeight = (element: HTMLDivElement | null) => { + const listRef = useRef(null); + const [scrolledFromTop, setScrolledFromTop] = useState(false); + const updateTopFade = () => { + const element = listRef.current?.getScrollableNode(); if (!element) return; - const update = () => { - const next = element.clientHeight >= WORKING_WINDOW_MAX_HEIGHT - 1; - setFull((current) => (current === next ? current : next)); - }; - update(); - return observeResize(element, update); + const next = element.scrollTop > 1; + setScrolledFromTop((current) => (current === next ? current : next)); }; const { workGroupViewState, onToggleWorkEntry } = ctx; // Entries opened in the history keep their state, and toggling one keeps the bar in place. @@ -947,8 +927,6 @@ function WorkingWindowView({ window, itemKey }: { window: WorkingWindow; itemKey state: workGroupViewState, onToggleEntry: () => onToggleWorkEntry("working-indicator-row"), }))(); - const open = window.historyOpen; - const shown = open ? window.items : window.items.slice(-WORKING_WINDOW_TAIL); const showThinking = !turnEnded && !pendingRequest && @@ -956,71 +934,46 @@ function WorkingWindowView({ window, itemKey }: { window: WorkingWindow; itemKey !isCompacting && !isPreparingWorktree && !workingWindowHasActiveCompaction(window); - if (shown.length === 0 && !showThinking) return null; - const openable = !open && shown.length > 0; + if (window.items.length === 0 && !showThinking) return null; const thinking = showThinking ? ( - +
0 ? "pt-1" : "pt-2"}> + +
) : null; return ( - {/* The bar is the keyboard control; the window is a larger pointer target for it. */} -
ctx.onToggleWorkingHistory(window.key) : undefined} - > - {open ? ( - ( -
- -
- )} - estimatedItemSize={compactWorkEntryHeight} - drawDistance={240} - recycleItems - maintainVisibleContentPosition - ListFooterComponent={thinking} - tabIndex={0} - role="region" - aria-label="Work history" - className="max-h-[min(18rem,50dvh)] min-w-0 overflow-x-hidden pt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" - /> - ) : ( -
- {shown.map((item) => ( -
- -
- ))} - {thinking} + ( +
+
)} -
+ estimatedItemSize={compactWorkEntryHeight} + drawDistance={240} + initialScrollAtEnd + maintainScrollAtEnd={{ + animated: false, + on: { dataChange: true, itemLayout: true, footerLayout: true }, + }} + maintainVisibleContentPosition + ListFooterComponent={thinking} + onScroll={updateTopFade} + tabIndex={0} + role="region" + aria-label="Work history" + className={cn( + "scrollbar-gutter-stable -me-(--chat-lane-reach) max-h-[220px] min-w-0 overflow-x-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", + scrolledFromTop && "[mask-image:linear-gradient(to_bottom,transparent,black_36px)]", + )} + /> ); } @@ -1036,11 +989,6 @@ function workingWindowItemIsLive(item: WorkingWindowItem | undefined): boolean { ); } -// Includes the 8px gap under the bar so the fade reaches its hairline. -const WORKING_WINDOW_MAX_HEIGHT = 220; - -const WORKING_WINDOW_TAIL = 12; - /** Running subagents outrank the diff; live statuses come from the thread's subagent roster. */ function WorkingAgentsSlot({ groups, diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index d17a96edd598d..0fb4767b6d31c 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -94,7 +94,6 @@ export interface TimelineRowSharedState { onRetryWorkspacePreparation: ((runId: RunId) => void) | null; onWorktreeSetupWorkLocally: (() => void) | null; onOpenWorktreeSetupTerminal: ((terminalId: string) => void) | null; - onToggleWorkingHistory: (key: string) => void; onFocusComposer: (() => void) | null; onInterrupt: (() => void) | null; workGroupViewState: WorkGroupViewState; diff --git a/apps/web/src/components/chat/useTimelineRows.ts b/apps/web/src/components/chat/useTimelineRows.ts index 84d42f1edb06b..a274f276b3618 100644 --- a/apps/web/src/components/chat/useTimelineRows.ts +++ b/apps/web/src/components/chat/useTimelineRows.ts @@ -94,9 +94,7 @@ export function useStableRows( } } const previous = state.identity === identity ? state.stable : { byId: new Map(), result: [] }; - const windowed = input.windowlessCitation - ? rows - : applyWorkingWindow(rows, input, input.workingHistoryKey); + const windowed = input.windowlessCitation ? rows : applyWorkingWindow(rows, input); const visibleRows = input.activeFindEntryId !== null && windowed.some( @@ -118,7 +116,6 @@ type StableTimelineRowsInput = Parameters[ >; readonly isWorking: boolean; readonly runlessWorkActive: boolean; - readonly workingHistoryKey: string | null; readonly windowlessCitation: boolean; readonly activeFindEntryId: string | null; };