authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-23 05:42:56-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-23 05:42:56-07:00
logea8c9e5ca3ace89cbf6cf0a2aa03047aab1d3ef9
tree0f0887c2b26bf8a2bfb14ab3408e73804e214046
parent25dcee00a6e12db2781a17b326e6e34de0d4ced7
signature Signed by PGP key B5690EEEBB952194

fix(web): stop tool calls from leaving a blank page in threads (#7971)


4 files changed, 208 insertions(+), 5 deletions(-)

apps/web/src/components/ChatView.logic.test.ts+109
...@@ -34,6 +34,7 @@ import {...@@ -34,6 +34,7 @@ import {
34 scheduleEnvironmentReconnectWarning,34 scheduleEnvironmentReconnectWarning,
35 startNewThreadForProject,35 startNewThreadForProject,
36 shouldDockDraftHeroForSubmission,36 shouldDockDraftHeroForSubmission,
37 shouldReleaseTimelineAnchorForToolActivity,
37 shouldShowBranchMismatchBanner,38 shouldShowBranchMismatchBanner,
38 shouldWriteThreadErrorToCurrentServerThread,39 shouldWriteThreadErrorToCurrentServerThread,
39} from "./ChatView.logic";40} from "./ChatView.logic";
...@@ -77,6 +78,114 @@ describe("draft hero submission transition", () => {...@@ -77,6 +78,114 @@ describe("draft hero submission transition", () => {
77 });78 });
78});79});
7980
81describe("shouldReleaseTimelineAnchorForToolActivity", () => {
82 const activeTurnId = TurnId.make("active-turn");
83 const anchorMessageId = MessageId.make("anchored-message");
84 const activeToolEntry = {
85 id: "tool-entry",
86 kind: "work" as const,
87 createdAt: now,
88 entry: {
89 id: "active-tool",
90 createdAt: now,
91 turnId: activeTurnId,
92 label: "Run command",
93 tone: "tool" as const,
94 command: "git status",
95 },
96 };
97
98 it("releases the send anchor for tool activity in the active turn", () => {
99 expect(
100 shouldReleaseTimelineAnchorForToolActivity({
101 anchorMessageId,
102 liveFollowEnabled: true,
103 runningTurnId: activeTurnId,
104 timelineEntries: [activeToolEntry],
105 }),
106 ).toBe(true);
107 });
108
109 it("keeps the anchor while the user reads history", () => {
110 expect(
111 shouldReleaseTimelineAnchorForToolActivity({
112 anchorMessageId,
113 liveFollowEnabled: false,
114 runningTurnId: activeTurnId,
115 timelineEntries: [activeToolEntry],
116 }),
117 ).toBe(false);
118 });
119
120 it("ignores tool activity from earlier turns", () => {
121 expect(
122 shouldReleaseTimelineAnchorForToolActivity({
123 anchorMessageId,
124 liveFollowEnabled: true,
125 runningTurnId: activeTurnId,
126 timelineEntries: [
127 {
128 ...activeToolEntry,
129 entry: {
130 ...activeToolEntry.entry,
131 turnId: TurnId.make("previous-turn"),
132 },
133 },
134 ],
135 }),
136 ).toBe(false);
137 });
138
139 it("ignores thinking and error rows without tool activity", () => {
140 expect(
141 shouldReleaseTimelineAnchorForToolActivity({
142 anchorMessageId,
143 liveFollowEnabled: true,
144 runningTurnId: activeTurnId,
145 timelineEntries: [
146 {
147 ...activeToolEntry,
148 entry: {
149 id: "thinking-entry",
150 createdAt: now,
151 turnId: activeTurnId,
152 label: "Thinking",
153 tone: "thinking",
154 },
155 },
156 {
157 ...activeToolEntry,
158 id: "error-entry",
159 entry: {
160 id: "error-entry",
161 createdAt: now,
162 turnId: activeTurnId,
163 label: "Provider error",
164 tone: "error",
165 },
166 },
167 ],
168 }),
169 ).toBe(false);
170 });
171
172 it("does nothing without an anchor or running turn", () => {
173 const input = {
174 anchorMessageId,
175 liveFollowEnabled: true,
176 runningTurnId: activeTurnId,
177 timelineEntries: [activeToolEntry],
178 };
179
180 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe(
181 false,
182 );
183 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe(
184 false,
185 );
186 });
187});
188
80describe("environment reconnect warning grace", () => {189describe("environment reconnect warning grace", () => {
81 afterEach(() => vi.useRealTimers());190 afterEach(() => vi.useRealTimers());
82191
apps/web/src/components/ChatView.logic.ts+27
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 type EnvironmentId,2 type EnvironmentId,
3 isProviderDriverKind,3 isProviderDriverKind,
4 ProjectId,4 ProjectId,
5 type MessageId,
5 type ModelSelection,6 type ModelSelection,
6 type ProviderDriverKind,7 type ProviderDriverKind,
7 type ServerProvider,8 type ServerProvider,
...@@ -22,6 +23,7 @@ import {...@@ -22,6 +23,7 @@ import {
22} from "../lib/terminalContext";23} from "../lib/terminalContext";
23import type { DraftThreadEnvMode } from "../composerDraftStore";24import type { DraftThreadEnvMode } from "../composerDraftStore";
24import type { ComposerSubmissionIntent } from "../composer-logic";25import type { ComposerSubmissionIntent } from "../composer-logic";
26import type { TimelineEntry } from "../session-logic";
2527
26export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";28export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
27export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;29export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
...@@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: {...@@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: {
42 );44 );
43}45}
4446
47export function shouldReleaseTimelineAnchorForToolActivity(input: {
48 anchorMessageId: MessageId | null;
49 liveFollowEnabled: boolean;
50 runningTurnId: TurnId | null;
51 timelineEntries: ReadonlyArray<TimelineEntry>;
52}): boolean {
53 if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) {
54 return false;
55 }
56
57 return input.timelineEntries.some((timelineEntry) => {
58 if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) {
59 return false;
60 }
61
62 const entry = timelineEntry.entry;
63 return (
64 entry.tone === "tool" ||
65 entry.itemType !== undefined ||
66 entry.requestKind !== undefined ||
67 (entry.command?.trim().length ?? 0) > 0
68 );
69 });
70}
71
45export function resolveDraftHeroState(input: {72export function resolveDraftHeroState(input: {
46 isLocalDraftThread: boolean;73 isLocalDraftThread: boolean;
47 hasTimelineEntries: boolean;74 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+29-5
...@@ -328,6 +328,7 @@ import {...@@ -328,6 +328,7 @@ import {
328 hasServerAcknowledgedLocalDispatch,328 hasServerAcknowledgedLocalDispatch,
329 isBranchMismatchDismissedForSession,329 isBranchMismatchDismissedForSession,
330 shouldDockDraftHeroForSubmission,330 shouldDockDraftHeroForSubmission,
331 shouldReleaseTimelineAnchorForToolActivity,
331 shouldShowBranchMismatchBanner,332 shouldShowBranchMismatchBanner,
332 getStartedThreadModelChangeBlockReason,333 getStartedThreadModelChangeBlockReason,
333 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,334 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
...@@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) {
1756 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));1757 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
1757 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);1758 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
1758 const activeLatestTurn = activeThread?.latestTurn ?? null;1759 const activeLatestTurn = activeThread?.latestTurn ?? null;
1760 const activeRunningTurnId =
1761 (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ??
1762 (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null);
1759 // Reading a finished thread clears the sidebar's Done badge. The visit is1763 // Reading a finished thread clears the sidebar's Done badge. The visit is
1760 // stamped at the turn's completion time — not now/updatedAt — so it clears1764 // stamped at the turn's completion time — not now/updatedAt — so it clears
1761 // exactly the completion the user is looking at: a wake or completion that1765 // exactly the completion the user is looking at: a wake or completion that
...@@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) {
3895 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;3899 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
3896 setTimelineLiveFollowEnabled(true);3900 setTimelineLiveFollowEnabled(true);
3897 pendingTimelineAnchorRef.current = null;3901 pendingTimelineAnchorRef.current = null;
3902 positionedTimelineAnchorRef.current = null;
3903 settledTimelineAnchorRef.current = null;
3898 activeTimelineAnchorIndexRef.current = null;3904 activeTimelineAnchorIndexRef.current = null;
3899 showScrollDebouncer.current.cancel();3905 showScrollDebouncer.current.cancel();
3900 setShowScrollToBottom(false);3906 setShowScrollToBottom(false);
...@@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) {
3903 void legendListRef.current?.scrollToEnd?.({ animated });3909 void legendListRef.current?.scrollToEnd?.({ animated });
3904 });3910 });
3905 }, []);3911 }, []);
3912 useLayoutEffect(() => {
3913 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
3914 return;
3915 }
3916
3917 if (
3918 shouldReleaseTimelineAnchorForToolActivity({
3919 anchorMessageId: timelineAnchorMessageId,
3920 liveFollowEnabled: timelineLiveFollowEnabled,
3921 runningTurnId: activeRunningTurnId,
3922 timelineEntries,
3923 })
3924 ) {
3925 scrollToEnd();
3926 }
3927 }, [
3928 activeRunningTurnId,
3929 scrollToEnd,
3930 timelineAnchorMessageId,
3931 timelineEntries,
3932 timelineLiveFollowEnabled,
3933 ]);
3906 useEffect(() => {3934 useEffect(() => {
3907 let removeListeners: (() => void) | null = null;3935 let removeListeners: (() => void) | null = null;
3908 let frame: number | null = null;3936 let frame: number | null = null;
...@@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) {
6640 listRef={legendListRef}6668 listRef={legendListRef}
6641 timelineEntries={timelineEntries}6669 timelineEntries={timelineEntries}
6642 latestTurn={activeLatestTurn}6670 latestTurn={activeLatestTurn}
6643 runningTurnId={6671 runningTurnId={activeRunningTurnId}
6644 activeThread.session?.status === "running"
6645 ? activeThread.session.activeTurnId
6646 : null
6647 }
6648 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}6672 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
6649 activeThreadEnvironmentId={activeThread.environmentId}6673 activeThreadEnvironmentId={activeThread.environmentId}
6650 routeThreadKey={routeThreadKey}6674 routeThreadKey={routeThreadKey}
apps/web/src/components/chat/MessagesTimeline.test.tsx+43
...@@ -558,6 +558,49 @@ describe("MessagesTimeline", () => {...@@ -558,6 +558,49 @@ describe("MessagesTimeline", () => {
558 expect(onAnchorReady).not.toHaveBeenCalled();558 expect(onAnchorReady).not.toHaveBeenCalled();
559 });559 });
560560
561 it("keeps reserved end space when tool work starts while reading history", () => {
562 const turnId = TurnId.make("turn-with-active-tool");
563 const firstEntry = buildUserTimelineEntry("Run the command.");
564 const markup = renderToStaticMarkup(
565 <MessagesTimeline
566 {...buildProps()}
567 isWorking
568 activeTurnStartedAt={MESSAGE_CREATED_AT}
569 latestTurn={{
570 turnId,
571 state: "running",
572 startedAt: MESSAGE_CREATED_AT,
573 completedAt: null,
574 }}
575 runningTurnId={turnId}
576 anchorMessageId={firstEntry.message.id}
577 liveFollowEnabled={false}
578 timelineEntries={[
579 firstEntry,
580 {
581 id: "entry-active-tool",
582 kind: "work",
583 createdAt: MESSAGE_CREATED_AT,
584 entry: {
585 id: "work-active-tool",
586 createdAt: MESSAGE_CREATED_AT,
587 turnId,
588 toolCallId: "call-active-tool",
589 label: "Run command",
590 tone: "tool",
591 itemType: "command_execution",
592 command: "git status",
593 toolLifecycleStatus: "inProgress",
594 },
595 },
596 ]}
597 />,
598 );
599
600 expect(markup).toContain('data-anchor-index="0"');
601 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
602 });
603
561 it("hands end-following back to the list once the send anchor is released", () => {604 it("hands end-following back to the list once the send anchor is released", () => {
562 const firstEntry = buildUserTimelineEntry("First prompt.");605 const firstEntry = buildUserTimelineEntry("First prompt.");
563 const secondEntry = {606 const secondEntry = {