| author | |
| committer | |
| log | ea8c9e5ca3ace89cbf6cf0a2aa03047aab1d3ef9 |
| tree | 0f0887c2b26bf8a2bfb14ab3408e73804e214046 |
| parent | 25dcee00a6e12db2781a17b326e6e34de0d4ced7 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 208 insertions(+), 5 deletions(-)
apps/web/src/components/ChatView.logic.test.ts+109| ... | ... | @@ -34,6 +34,7 @@ import { |
| 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", () => { |
| 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 |
apps/web/src/components/ChatView.logic.ts+27| ... | ... | @@ -2,6 +2,7 @@ import { |
| 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 { |
| 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: { |
| 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; |
apps/web/src/components/ChatView.tsx+29-5| ... | ... | @@ -328,6 +328,7 @@ import { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+43| ... | ... | @@ -558,6 +558,49 @@ describe("MessagesTimeline", () => { |
| 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 = { |