Unified · Split
| @@ -34,6 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | scheduleEnvironmentReconnectWarning, |
| 35 | 35 | startNewThreadForProject, |
| 36 | 36 | shouldDockDraftHeroForSubmission, |
| 37 | + shouldReleaseTimelineAnchorForToolActivity, | |
| 37 | 38 | shouldShowBranchMismatchBanner, |
| 38 | 39 | shouldWriteThreadErrorToCurrentServerThread, |
| 39 | 40 | } from "./ChatView.logic"; |
| @@ -77,6 +78,114 @@ describe("draft hero submission transition", () => { · 37 unmodified lines | ||
| 77 | 78 | }); |
| 78 | 79 | }); |
| 79 | 80 | |
| 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 | + | |
| 80 | 189 | describe("environment reconnect warning grace", () => { |
| 81 | 190 | afterEach(() => vi.useRealTimers()); |
| 82 | 191 | |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | isProviderDriverKind, |
| 4 | 4 | ProjectId, |
| 5 | + type MessageId, | |
| 5 | 6 | type ModelSelection, |
| 6 | 7 | type ProviderDriverKind, |
| 7 | 8 | type ServerProvider, |
| @@ -22,6 +23,7 @@ import { · 14 unmodified lines | ||
| 22 | 23 | } from "../lib/terminalContext"; |
| 23 | 24 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 24 | 25 | import type { ComposerSubmissionIntent } from "../composer-logic"; |
| 26 | +import type { TimelineEntry } from "../session-logic"; | |
| 25 | 27 | |
| 26 | 28 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 27 | 29 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| @@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: { · 14 unmodified lines | ||
| 42 | 44 | ); |
| 43 | 45 | } |
| 44 | 46 | |
| 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 | + | |
| 45 | 72 | export function resolveDraftHeroState(input: { |
| 46 | 73 | isLocalDraftThread: boolean; |
| 47 | 74 | hasTimelineEntries: boolean; |
| @@ -328,6 +328,7 @@ import { · 327 unmodified lines | ||
| 328 | 328 | hasServerAcknowledgedLocalDispatch, |
| 329 | 329 | isBranchMismatchDismissedForSession, |
| 330 | 330 | shouldDockDraftHeroForSubmission, |
| 331 | + shouldReleaseTimelineAnchorForToolActivity, | |
| 331 | 332 | shouldShowBranchMismatchBanner, |
| 332 | 333 | getStartedThreadModelChangeBlockReason, |
| 333 | 334 | LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, |
| @@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) { · 1422 unmodified lines | ||
| 1756 | 1757 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1757 | 1758 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1758 | 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 | 1763 | // Reading a finished thread clears the sidebar's Done badge. The visit is |
| 1760 | 1764 | // stamped at the turn's completion time — not now/updatedAt — so it clears |
| 1761 | 1765 | // exactly the completion the user is looking at: a wake or completion that |
| @@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) { · 2133 unmodified lines | ||
| 3895 | 3899 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 3896 | 3900 | setTimelineLiveFollowEnabled(true); |
| 3897 | 3901 | pendingTimelineAnchorRef.current = null; |
| 3902 | + positionedTimelineAnchorRef.current = null; | |
| 3903 | + settledTimelineAnchorRef.current = null; | |
| 3898 | 3904 | activeTimelineAnchorIndexRef.current = null; |
| 3899 | 3905 | showScrollDebouncer.current.cancel(); |
| 3900 | 3906 | setShowScrollToBottom(false); |
| @@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) { · 2 unmodified lines | ||
| 3903 | 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 | 3934 | useEffect(() => { |
| 3907 | 3935 | let removeListeners: (() => void) | null = null; |
| 3908 | 3936 | let frame: number | null = null; |
| @@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) { · 2731 unmodified lines | ||
| 6640 | 6668 | listRef={legendListRef} |
| 6641 | 6669 | timelineEntries={timelineEntries} |
| 6642 | 6670 | latestTurn={activeLatestTurn} |
| 6643 | − runningTurnId={ | |
| 6644 | − activeThread.session?.status === "running" | |
| 6645 | − ? activeThread.session.activeTurnId | |
| 6646 | − : null | |
| 6647 | − } | |
| 6671 | + runningTurnId={activeRunningTurnId} | |
| 6648 | 6672 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 6649 | 6673 | activeThreadEnvironmentId={activeThread.environmentId} |
| 6650 | 6674 | routeThreadKey={routeThreadKey} |
| @@ -558,6 +558,49 @@ describe("MessagesTimeline", () => { · 557 unmodified lines | ||
| 558 | 558 | expect(onAnchorReady).not.toHaveBeenCalled(); |
| 559 | 559 | }); |
| 560 | 560 | |
| 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 | 604 | it("hands end-following back to the list once the send anchor is released", () => { |
| 562 | 605 | const firstEntry = buildUserTimelineEntry("First prompt."); |
| 563 | 606 | const secondEntry = { |