History

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

Theo Browne committed 2 months ago

Commit ea8c9e51 parent 25dcee04 files · +208 −5Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.logic.test.ts +109 −0
  2. apps/web/src/components/ChatView.logic.ts +27 −0
  3. apps/web/src/components/ChatView.tsx +29 −5
  4. apps/web/src/components/chat/MessagesTimeline.test.tsx +43 −0
apps/web/src/components/ChatView.logic.test.ts+109−0
@@ -34,6 +34,7 @@ import { · 33 unmodified lines
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", () => { · 37 unmodified lines
7778 });
7879 });
7980
81+describe("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+
80189 describe("environment reconnect warning grace", () => {
81190 afterEach(() => vi.useRealTimers());
82191
apps/web/src/components/ChatView.logic.ts+27−0
@@ -2,6 +2,7 @@ import { · 1 unmodified line
22 type EnvironmentId,
33 isProviderDriverKind,
44 ProjectId,
5+ type MessageId,
56 type ModelSelection,
67 type ProviderDriverKind,
78 type ServerProvider,
@@ -22,6 +23,7 @@ import { · 14 unmodified lines
2223 } from "../lib/terminalContext";
2324 import type { DraftThreadEnvMode } from "../composerDraftStore";
2425 import type { ComposerSubmissionIntent } from "../composer-logic";
26+import type { TimelineEntry } from "../session-logic";
2527
2628 export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
2729 export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
@@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: { · 14 unmodified lines
4244 );
4345 }
4446
47+export 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+
4572 export function resolveDraftHeroState(input: {
4673 isLocalDraftThread: boolean;
4774 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+29−5
@@ -328,6 +328,7 @@ import { · 327 unmodified lines
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) { · 1422 unmodified lines
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) { · 2133 unmodified lines
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) { · 2 unmodified lines
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) { · 2731 unmodified lines
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−0
@@ -558,6 +558,49 @@ describe("MessagesTimeline", () => { · 557 unmodified lines
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 = {