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 {
3434 scheduleEnvironmentReconnectWarning,
3535 startNewThreadForProject,
3636 shouldDockDraftHeroForSubmission,
37 shouldReleaseTimelineAnchorForToolActivity,
3738 shouldShowBranchMismatchBanner,
3839 shouldWriteThreadErrorToCurrentServerThread,
3940} from "./ChatView.logic";
......@@ -77,6 +78,114 @@ describe("draft hero submission transition", () => {
7778 });
7879});
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
80189describe("environment reconnect warning grace", () => {
81190 afterEach(() => vi.useRealTimers());
82191
apps/web/src/components/ChatView.logic.ts+27
......@@ -2,6 +2,7 @@ import {
22 type EnvironmentId,
33 isProviderDriverKind,
44 ProjectId,
5 type MessageId,
56 type ModelSelection,
67 type ProviderDriverKind,
78 type ServerProvider,
......@@ -22,6 +23,7 @@ import {
2223} from "../lib/terminalContext";
2324import type { DraftThreadEnvMode } from "../composerDraftStore";
2425import type { ComposerSubmissionIntent } from "../composer-logic";
26import type { TimelineEntry } from "../session-logic";
2527
2628export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2729export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
......@@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: {
4244 );
4345}
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
4572export function resolveDraftHeroState(input: {
4673 isLocalDraftThread: boolean;
4774 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+29-5
......@@ -328,6 +328,7 @@ import {
328328 hasServerAcknowledgedLocalDispatch,
329329 isBranchMismatchDismissedForSession,
330330 shouldDockDraftHeroForSubmission,
331 shouldReleaseTimelineAnchorForToolActivity,
331332 shouldShowBranchMismatchBanner,
332333 getStartedThreadModelChangeBlockReason,
333334 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) {
17561757 return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey));
17571758 }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]);
17581759 const activeLatestTurn = activeThread?.latestTurn ?? null;
1760 const activeRunningTurnId =
1761 (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ??
1762 (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null);
17591763 // Reading a finished thread clears the sidebar's Done badge. The visit is
17601764 // stamped at the turn's completion time — not now/updatedAt — so it clears
17611765 // exactly the completion the user is looking at: a wake or completion that
......@@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) {
38953899 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
38963900 setTimelineLiveFollowEnabled(true);
38973901 pendingTimelineAnchorRef.current = null;
3902 positionedTimelineAnchorRef.current = null;
3903 settledTimelineAnchorRef.current = null;
38983904 activeTimelineAnchorIndexRef.current = null;
38993905 showScrollDebouncer.current.cancel();
39003906 setShowScrollToBottom(false);
......@@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) {
39033909 void legendListRef.current?.scrollToEnd?.({ animated });
39043910 });
39053911 }, []);
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 ]);
39063934 useEffect(() => {
39073935 let removeListeners: (() => void) | null = null;
39083936 let frame: number | null = null;
......@@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) {
66406668 listRef={legendListRef}
66416669 timelineEntries={timelineEntries}
66426670 latestTurn={activeLatestTurn}
6643 runningTurnId={
6644 activeThread.session?.status === "running"
6645 ? activeThread.session.activeTurnId
6646 : null
6647 }
6671 runningTurnId={activeRunningTurnId}
66486672 turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId}
66496673 activeThreadEnvironmentId={activeThread.environmentId}
66506674 routeThreadKey={routeThreadKey}
apps/web/src/components/chat/MessagesTimeline.test.tsx+43
......@@ -558,6 +558,49 @@ describe("MessagesTimeline", () => {
558558 expect(onAnchorReady).not.toHaveBeenCalled();
559559 });
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
561604 it("hands end-following back to the list once the send anchor is released", () => {
562605 const firstEntry = buildUserTimelineEntry("First prompt.");
563606 const secondEntry = {