diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44429614b44..60d11399d6f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4729,7 +4729,6 @@ function ChatViewContent(props: ChatViewProps) { { expect(assistantRow?.showAssistantMeta).toBe(false); expect(assistantRow?.showAssistantCopyButton).toBe(false); }); + + it("marks only the unsettled turn work row as in progress", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "old-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:08Z", + entry: { + id: "old-work", + createdAt: "2026-01-01T00:00:08Z", + turnId: "turn-1" as never, + label: "Read files", + tone: "tool" as const, + }, + }, + { + id: "user-entry", + kind: "message", + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "user-message" as never, + role: "user", + text: "Continue", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "active-work-entry", + kind: "work", + createdAt: "2026-01-01T00:00:18Z", + entry: { + id: "active-work", + createdAt: "2026-01-01T00:00:18Z", + turnId: "turn-2" as never, + label: "Run tests", + tone: "tool" as const, + }, + }, + ], + latestTurn: { + turnId: "turn-2" as never, + state: "running", + startedAt: "2026-01-01T00:00:16Z", + completedAt: null, + }, + expandedTurnIds: new Set(["turn-1" as never]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const workRows = rows.filter((row) => row.kind === "work"); + + expect(workRows.map((row) => [row.id, row.turnInProgress])).toEqual([ + ["old-work-entry", false], + ["active-work-entry", true], + ]); + }); }); describe("computeStableMessagesTimelineRows", () => { @@ -987,6 +1050,92 @@ describe("computeStableMessagesTimelineRows", () => { expect(repeated.result[0]).toBe(initial.result[0]); }); + it("reuses settled work rows when a different turn stops running", () => { + const timelineEntries = [ + { + id: "old-work-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:08Z", + entry: { + id: "old-work", + createdAt: "2026-01-01T00:00:08Z", + turnId: "turn-1" as never, + label: "Read files", + tone: "tool" as const, + }, + }, + { + id: "user-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:12Z", + message: { + id: "user-message" as never, + role: "user" as const, + text: "Continue", + turnId: null, + createdAt: "2026-01-01T00:00:12Z", + updatedAt: "2026-01-01T00:00:12Z", + streaming: false, + }, + }, + { + id: "active-work-entry", + kind: "work" as const, + createdAt: "2026-01-01T00:00:18Z", + entry: { + id: "active-work", + createdAt: "2026-01-01T00:00:18Z", + turnId: "turn-2" as never, + label: "Run tests", + tone: "tool" as const, + }, + }, + ]; + + const runningRows = deriveMessagesTimelineRows({ + timelineEntries, + latestTurn: { + turnId: "turn-2" as never, + state: "running", + startedAt: "2026-01-01T00:00:16Z", + completedAt: null, + }, + expandedTurnIds: new Set(["turn-1" as never, "turn-2" as never]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + const initial = computeStableMessagesTimelineRows(runningRows, { + byId: new Map(), + result: [], + }); + + const settledRows = deriveMessagesTimelineRows({ + timelineEntries, + latestTurn: { + turnId: "turn-2" as never, + state: "completed", + startedAt: "2026-01-01T00:00:16Z", + completedAt: "2026-01-01T00:00:30Z", + }, + expandedTurnIds: new Set(["turn-1" as never, "turn-2" as never]), + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + const repeated = computeStableMessagesTimelineRows(settledRows, initial); + + expect(repeated.result.find((row) => row.id === "old-work-entry")).toBe( + initial.result.find((row) => row.id === "old-work-entry"), + ); + expect(repeated.result.find((row) => row.id === "active-work-entry")).not.toBe( + initial.result.find((row) => row.id === "active-work-entry"), + ); + }); + it("returns a new result when row order changes without content changes", () => { const firstUserMessage = { id: "user-1" as never, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1426f1deee2..1d5b1f883f1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -41,6 +41,7 @@ export type MessagesTimelineRow = id: string; createdAt: string; groupedEntries: WorkLogEntry[]; + turnInProgress: boolean; } | { kind: "turn-fold"; @@ -361,6 +362,9 @@ export function deriveMessagesTimelineRows(input: { id: timelineEntry.id, createdAt: timelineEntry.createdAt, groupedEntries, + turnInProgress: + unsettledTurnId !== null && + groupedEntries.some((entry) => entry.turnId === unsettledTurnId), }); index = cursor - 1; continue; @@ -460,7 +464,10 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean return a.proposedPlan === (b as typeof a).proposedPlan; case "work": - return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); + return ( + a.turnInProgress === (b as typeof a).turnInProgress && + Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries) + ); case "message": { const bm = b as typeof a; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 54ad25df7b7..02e8bfb8937 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -93,7 +93,6 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; function buildProps() { return { isWorking: false, - activeTurnInProgress: false, activeTurnStartedAt: null, listRef: createRef(), latestTurn: null, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 69c0f2d0260..8d380baf2a0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -17,8 +17,10 @@ import { useMemo, useRef, useState, + type Dispatch, type KeyboardEvent, type ReactNode, + type SetStateAction, } from "react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; @@ -129,14 +131,12 @@ interface TimelineRowSharedState { onToggleTurnFold: (turnId: TurnId) => void; } -interface TimelineRowActivityState { - isWorking: boolean; - isRevertingCheckpoint: boolean; - activeTurnInProgress: boolean; +interface TimelineRevertControlsState { + disabled: boolean; } const TimelineRowCtx = createContext(null!); -const TimelineRowActivityCtx = createContext(null!); +const TimelineRevertControlsCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; @@ -147,7 +147,6 @@ const EMPTY_TIMELINE_SKILLS: ReadonlyArray; timelineEntries: ReturnType; @@ -174,7 +173,6 @@ interface MessagesTimelineProps { export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, - activeTurnInProgress, activeTurnStartedAt, listRef, timelineEntries, @@ -215,52 +213,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return next; }); }, []); - useEffect(() => { - if (!foldToggleSettling) { - return; - } - let secondFrameId: number | null = null; - const firstFrameId = window.requestAnimationFrame(() => { - secondFrameId = window.requestAnimationFrame(() => { - setFoldToggleSettling(false); - }); - }); - return () => { - window.cancelAnimationFrame(firstFrameId); - if (secondFrameId !== null) { - window.cancelAnimationFrame(secondFrameId); - } - }; - }, [foldToggleSettling]); + useFoldToggleSettlingReset(foldToggleSettling, setFoldToggleSettling); // An in-session interrupt leaves its turn expanded so the user keeps their // place; the next turn (or a reload, since this is local state) folds it. - const previousLatestTurnRef = useRef(latestTurn); - useEffect(() => { - const previous = previousLatestTurnRef.current; - previousLatestTurnRef.current = latestTurn; - if (!latestTurn || previous?.turnId === undefined) { - return; - } - if (latestTurn.turnId === previous.turnId) { - if (previous.state === "running" && latestTurn.state === "interrupted") { - setExpandedTurnIds((existing) => { - const next = new Set(existing); - next.add(latestTurn.turnId); - return next; - }); - } - return; - } - setExpandedTurnIds((existing) => { - if (!existing.has(previous.turnId)) { - return existing; - } - const next = new Set(existing); - next.delete(previous.turnId); - return next; - }); - }, [latestTurn]); + useLatestTurnFoldState(latestTurn, setExpandedTurnIds); const rawRows = useMemo( () => @@ -292,23 +249,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [listRef, onIsAtEndChange]); - const previousRowCountRef = useRef(rows.length); - useEffect(() => { - const previousRowCount = previousRowCountRef.current; - previousRowCountRef.current = rows.length; - - if (previousRowCount > 0 || rows.length === 0) { - return; - } - - onIsAtEndChange(true); - const frameId = window.requestAnimationFrame(() => { - void listRef.current?.scrollToEnd?.({ animated: false }); - }); - return () => { - window.cancelAnimationFrame(frameId); - }; - }, [listRef, onIsAtEndChange, rows.length]); + useInitialTimelineEndScroll(rows.length, listRef, onIsAtEndChange); const sharedState = useMemo( () => ({ @@ -339,13 +280,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onToggleTurnFold, ], ); - const activityState = useMemo( + const revertControlsState = useMemo( () => ({ - isWorking, - isRevertingCheckpoint, - activeTurnInProgress, + disabled: isRevertingCheckpoint || isWorking, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking], + [isRevertingCheckpoint, isWorking], ); // Stable renderItem — no closure deps. Row components read shared state @@ -371,7 +310,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( - + ref={listRef} data={rows} @@ -387,11 +326,92 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListHeaderComponent={TIMELINE_LIST_HEADER} ListFooterComponent={TIMELINE_LIST_FOOTER} /> - + ); }); +function useFoldToggleSettlingReset( + foldToggleSettling: boolean, + setFoldToggleSettling: Dispatch>, +) { + useEffect(() => { + if (!foldToggleSettling) { + return; + } + let secondFrameId: number | null = null; + const firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(() => { + setFoldToggleSettling(false); + }); + }); + return () => { + window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== null) { + window.cancelAnimationFrame(secondFrameId); + } + }; + }, [foldToggleSettling, setFoldToggleSettling]); +} + +function useLatestTurnFoldState( + latestTurn: TimelineLatestTurn | null, + setExpandedTurnIds: Dispatch>>, +) { + const previousLatestTurnRef = useRef(latestTurn); + + useEffect(() => { + const previous = previousLatestTurnRef.current; + previousLatestTurnRef.current = latestTurn; + if (!latestTurn || previous?.turnId === undefined) { + return; + } + if (latestTurn.turnId === previous.turnId) { + if (previous.state === "running" && latestTurn.state === "interrupted") { + setExpandedTurnIds((existing) => { + const next = new Set(existing); + next.add(latestTurn.turnId); + return next; + }); + } + return; + } + setExpandedTurnIds((existing) => { + if (!existing.has(previous.turnId)) { + return existing; + } + const next = new Set(existing); + next.delete(previous.turnId); + return next; + }); + }, [latestTurn, setExpandedTurnIds]); +} + +function useInitialTimelineEndScroll( + rowCount: number, + listRef: React.RefObject, + onIsAtEndChange: (isAtEnd: boolean) => void, +) { + const previousRowCountRef = useRef(rowCount); + + useEffect(() => { + const previousRowCount = previousRowCountRef.current; + previousRowCountRef.current = rowCount; + + if (previousRowCount > 0 || rowCount === 0) { + return; + } + + onIsAtEndChange(true); + const frameId = window.requestAnimationFrame(() => { + void listRef.current?.scrollToEnd?.({ animated: false }); + }); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [listRef, onIsAtEndChange, rowCount]); +} + function keyExtractor(item: MessagesTimelineRow) { return item.id; } @@ -422,7 +442,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time data-message-id={row.kind === "message" ? row.message.id : undefined} data-message-role={row.kind === "message" ? row.message.role : undefined} > - {row.kind === "work" ? : null} + {row.kind === "work" ? : null} {row.kind === "turn-fold" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} {row.kind === "message" && row.message.role === "assistant" ? ( @@ -540,7 +560,7 @@ function UserTimelineRow({ row }: { row: Extract @@ -550,7 +570,7 @@ function RevertUserMessageButton({ messageId }: { messageId: MessageId }) { type="button" size="xs" variant="ghost" - disabled={activity.isRevertingCheckpoint || activity.isWorking} + disabled={controls.disabled} onClick={() => ctx.onRevertUserMessage(messageId)} aria-label="Revert to this message" /> @@ -691,7 +711,16 @@ function WorkingTimelineRow({ row }: { row: Extract(null); const initialText = formatWorkingTimerNow(createdAt); + useWorkingTimerText(textRef, createdAt); + + return ( + + {initialText} + + ); +} +function useWorkingTimerText(textRef: React.RefObject, createdAt: string) { useEffect(() => { const updateText = () => { if (textRef.current) { @@ -701,13 +730,7 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { updateText(); const id = setInterval(updateText, 1000); return () => clearInterval(id); - }, [createdAt]); - - return ( - - {initialText} - - ); + }, [createdAt, textRef]); } // --------------------------------------------------------------------------- @@ -717,14 +740,15 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { /** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */ const WorkGroupSection = memo(function WorkGroupSection({ - groupedEntries, + row, }: { - groupedEntries: Extract["groupedEntries"]; + row: Extract; }) { const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); const sectionRef = useRef(null); const anchorBottomBeforeToggleRef = useRef(null); + const { groupedEntries, turnInProgress } = row; const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), [groupedEntries], @@ -742,31 +766,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ : `${nonEmptyEntries.length} tool calls` : "Work Log"; - useLayoutEffect(() => { - const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current; - anchorBottomBeforeToggleRef.current = null; - - if (anchorBottomBeforeToggle === null) { - return; - } - - const section = sectionRef.current; - if (!section) { - return; - } - - const delta = section.getBoundingClientRect().bottom - anchorBottomBeforeToggle; - if (Math.abs(delta) < 0.5) { - return; - } - - const scroller = findNearestVerticalScroller(section); - if (scroller) { - scroller.scrollTop += delta; - } else { - window.scrollBy(0, delta); - } - }, [isExpanded]); + usePreserveWorkGroupAnchorOnExpand(isExpanded, sectionRef, anchorBottomBeforeToggleRef); const toggleExpanded = () => { anchorBottomBeforeToggleRef.current = @@ -788,6 +788,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ ))} @@ -818,6 +819,38 @@ const WorkGroupSection = memo(function WorkGroupSection({ ); }); +function usePreserveWorkGroupAnchorOnExpand( + isExpanded: boolean, + sectionRef: React.RefObject, + anchorBottomBeforeToggleRef: React.RefObject, +) { + useLayoutEffect(() => { + const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current; + anchorBottomBeforeToggleRef.current = null; + + if (anchorBottomBeforeToggle === null) { + return; + } + + const section = sectionRef.current; + if (!section) { + return; + } + + const delta = section.getBoundingClientRect().bottom - anchorBottomBeforeToggle; + if (Math.abs(delta) < 0.5) { + return; + } + + const scroller = findNearestVerticalScroller(section); + if (scroller) { + scroller.scrollTop += delta; + } else { + window.scrollBy(0, delta); + } + }, [anchorBottomBeforeToggleRef, isExpanded, sectionRef]); +} + function findNearestVerticalScroller(element: HTMLElement): HTMLElement | null { let parent = element.parentElement; while (parent) { @@ -1545,10 +1578,10 @@ const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation( const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; + turnInProgress: boolean; workspaceRoot: string | undefined; }) { - const { workEntry, workspaceRoot } = props; - const activity = use(TimelineRowActivityCtx); + const { workEntry, turnInProgress, workspaceRoot } = props; const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; @@ -1583,7 +1616,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { : showDestructiveRowStyle ? "font-medium text-destructive" : "font-medium text-foreground/82"; - const turnSettled = !activity.activeTurnInProgress; + const turnSettled = !turnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = workEntryIndicatesToolSuccess(workEntry) ||