From fa66b6863a7a5d2d98958ff84d8592afd0749015 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:18:13 -0700 Subject: [PATCH 1/3] fix(web): stop tool calls from leaving a blank page in threads --- .../components/chat/MessagesTimeline.test.tsx | 42 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 10 ++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4647384fcf71..d588f3e8d39f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -558,6 +558,48 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).not.toHaveBeenCalled(); }); + it("does not reserve a blank viewport after the active turn starts tool work", () => { + const turnId = TurnId.make("turn-with-active-tool"); + const firstEntry = buildUserTimelineEntry("Run the command."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain("data-anchor-index="); + expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); + }); + it("hands end-following back to the list once the send anchor is released", () => { const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index af920c0d6156..67c54e28723d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -271,7 +271,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, - anchorMessageId, + anchorMessageId: requestedAnchorMessageId, onAnchorReady, contentInsetEndAdjustment, liveFollowEnabled, @@ -281,6 +281,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ topFadeEnabled = false, loadEarlier = null, }: MessagesTimelineProps) { + const anchorMessageId = useMemo( + () => + requestedAnchorMessageId !== null && + timelineEntries.some((entry) => entry.kind === "work" && workLogEntryIsToolLike(entry.entry)) + ? null + : requestedAnchorMessageId, + [requestedAnchorMessageId, timelineEntries], + ); const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); From 25c7e3542c3b5a472e9e8fd914f79fb8f5925cc2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:49:33 -0700 Subject: [PATCH 2/3] fix(web): release thread scroll anchors without disrupting manual navigation --- .../web/src/components/ChatView.logic.test.ts | 109 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 27 +++++ apps/web/src/components/ChatView.tsx | 37 +++++- .../components/chat/MessagesTimeline.test.tsx | 7 +- .../src/components/chat/MessagesTimeline.tsx | 10 +- 5 files changed, 173 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 66e83f1f7e62..cb814dace2e5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -34,6 +34,7 @@ import { scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -77,6 +78,114 @@ describe("draft hero submission transition", () => { }); }); +describe("shouldReleaseTimelineAnchorForToolActivity", () => { + const activeTurnId = TurnId.make("active-turn"); + const anchorMessageId = MessageId.make("anchored-message"); + const activeToolEntry = { + id: "tool-entry", + kind: "work" as const, + createdAt: now, + entry: { + id: "active-tool", + createdAt: now, + turnId: activeTurnId, + label: "Run command", + tone: "tool" as const, + command: "git status", + }, + }; + + it("releases the send anchor for tool activity in the active turn", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(true); + }); + + it("keeps the anchor while the user reads history", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: false, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(false); + }); + + it("ignores tool activity from earlier turns", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + ...activeToolEntry.entry, + turnId: TurnId.make("previous-turn"), + }, + }, + ], + }), + ).toBe(false); + }); + + it("ignores thinking and error rows without tool activity", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + id: "thinking-entry", + createdAt: now, + turnId: activeTurnId, + label: "Thinking", + tone: "thinking", + }, + }, + { + ...activeToolEntry, + id: "error-entry", + entry: { + id: "error-entry", + createdAt: now, + turnId: activeTurnId, + label: "Provider error", + tone: "error", + }, + }, + ], + }), + ).toBe(false); + }); + + it("does nothing without an anchor or running turn", () => { + const input = { + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }; + + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( + false, + ); + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( + false, + ); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b790aa025a1f..83bea23b65e2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type MessageId, type ModelSelection, type ProviderDriverKind, type ServerProvider, @@ -22,6 +23,7 @@ import { } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { TimelineEntry } from "../session-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: { ); } +export function shouldReleaseTimelineAnchorForToolActivity(input: { + anchorMessageId: MessageId | null; + liveFollowEnabled: boolean; + runningTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { + return false; + } + + return input.timelineEntries.some((timelineEntry) => { + if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { + return false; + } + + const entry = timelineEntry.entry; + return ( + entry.tone === "tool" || + entry.itemType !== undefined || + entry.requestKind !== undefined || + (entry.command?.trim().length ?? 0) > 0 + ); + }); +} + export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbee2d1709f3..b802b4cb3c42 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -328,6 +328,7 @@ import { hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -1756,6 +1757,12 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + const activeRunningTurnId = + activeThread?.session?.status === "running" + ? activeThread.session.activeTurnId + : activeLatestTurn?.state === "running" + ? activeLatestTurn.turnId + : null; // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -3895,6 +3902,8 @@ function ChatViewContent(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); @@ -3903,6 +3912,28 @@ function ChatViewContent(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEnd(); + } + }, [ + activeRunningTurnId, + scrollToEnd, + timelineAnchorMessageId, + timelineEntries, + timelineLiveFollowEnabled, + ]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -6640,11 +6671,7 @@ function ChatViewContent(props: ChatViewProps) { listRef={legendListRef} timelineEntries={timelineEntries} latestTurn={activeLatestTurn} - runningTurnId={ - activeThread.session?.status === "running" - ? activeThread.session.activeTurnId - : null - } + runningTurnId={activeRunningTurnId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d588f3e8d39f..6d007a5b6568 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -558,7 +558,7 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).not.toHaveBeenCalled(); }); - it("does not reserve a blank viewport after the active turn starts tool work", () => { + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); const markup = renderToStaticMarkup( @@ -574,6 +574,7 @@ describe("MessagesTimeline", () => { }} runningTurnId={turnId} anchorMessageId={firstEntry.message.id} + liveFollowEnabled={false} timelineEntries={[ firstEntry, { @@ -596,8 +597,8 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).not.toContain("data-anchor-index="); - expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); + expect(markup).toContain('data-anchor-index="0"'); + expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); }); it("hands end-following back to the list once the send anchor is released", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 67c54e28723d..af920c0d6156 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -271,7 +271,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, - anchorMessageId: requestedAnchorMessageId, + anchorMessageId, onAnchorReady, contentInsetEndAdjustment, liveFollowEnabled, @@ -281,14 +281,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ topFadeEnabled = false, loadEarlier = null, }: MessagesTimelineProps) { - const anchorMessageId = useMemo( - () => - requestedAnchorMessageId !== null && - timelineEntries.some((entry) => entry.kind === "work" && workLogEntryIsToolLike(entry.entry)) - ? null - : requestedAnchorMessageId, - [requestedAnchorMessageId, timelineEntries], - ); const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); From 66566e2a4fafe6dc0dde5bab2d008d4d603aab17 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 05:14:27 -0700 Subject: [PATCH 3/3] fix(web): recover running turn when session turn id is missing --- apps/web/src/components/ChatView.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b802b4cb3c42..46ed051154a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1758,11 +1758,8 @@ function ChatViewContent(props: ChatViewProps) { }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; const activeRunningTurnId = - activeThread?.session?.status === "running" - ? activeThread.session.activeTurnId - : activeLatestTurn?.state === "running" - ? activeLatestTurn.turnId - : null; + (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? + (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that