| 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,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 | }); |
| 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 | describe("environment reconnect warning grace", () => { | 189 | describe("environment reconnect warning grace", () => { |
| 81 | afterEach(() => vi.useRealTimers()); | 190 | afterEach(() => vi.useRealTimers()); |
| 82 | 191 |
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"; |
| 23 | import type { DraftThreadEnvMode } from "../composerDraftStore"; | 24 | import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 24 | import type { ComposerSubmissionIntent } from "../composer-logic"; | 25 | import type { ComposerSubmissionIntent } from "../composer-logic"; |
| 26 | import type { TimelineEntry } from "../session-logic"; | ||
| 25 | 27 | ||
| 26 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 28 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 27 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; | 29 | export 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 | } |
| 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 | export function resolveDraftHeroState(input: { | 72 | export 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 is | 1763 | // 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 clears | 1764 | // 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 that | 1765 | // 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 | }); |
| 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 | 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 = { |