| author | |
| committer | |
| log | 2274444e92275fda2033de6636e3143ac3599ffb |
| tree | a3dacd1fd8a318a3e17bb5f27033578c0deef1a3 |
| parent | f34b9d31b3ddbd53a553141fd769721fca4b8eb5 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 238 insertions(+), 68 deletions(-)
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+37-12| ... | @@ -79,6 +79,7 @@ import { | ... | @@ -79,6 +79,7 @@ import { |
| 79 | } from "./ThreadComposer"; | 79 | } from "./ThreadComposer"; |
| 80 | import { ThreadFeed } from "./ThreadFeed"; | 80 | import { ThreadFeed } from "./ThreadFeed"; |
| 81 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 81 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 82 | import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; | ||
| 82 | 83 | ||
| 83 | export interface ThreadDetailScreenProps { | 84 | export interface ThreadDetailScreenProps { |
| 84 | readonly selectedThread: OrchestrationThreadShell; | 85 | readonly selectedThread: OrchestrationThreadShell; |
| ... | @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 257 | const listRef = useRef<LegendListRef>(null); | 258 | const listRef = useRef<LegendListRef>(null); |
| 258 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); | 259 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); |
| 259 | const selectedThreadKeyRef = useRef(selectedThreadKey); | 260 | const selectedThreadKeyRef = useRef(selectedThreadKey); |
| 260 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); | 261 | const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null); |
| 261 | const [composerExpanded, setComposerExpanded] = useState(false); | 262 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 262 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); | 263 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 264 | const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null); | ||
| 263 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); | 265 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); |
| 264 | // Android keys the safe-area padding on keyboard visibility (#5988): the | 266 | // Android keys the safe-area padding on keyboard visibility (#5988): the |
| 265 | // back gesture closes the keyboard while the editor stays focused, and a | 267 | // back gesture closes the keyboard while the editor stays focused, and a |
| ... | @@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 458 | 460 | ||
| 459 | useEffect(() => { | 461 | useEffect(() => { |
| 460 | setAnchorMessageId(null); | 462 | setAnchorMessageId(null); |
| 461 | lastScrolledAnchorMessageIdRef.current = null; | 463 | setSubmittedMessageId(null); |
| 464 | lastScrolledSubmittedMessageIdRef.current = null; | ||
| 462 | setEndFollowEnabled(true); | 465 | setEndFollowEnabled(true); |
| 463 | freeze.set(false); | 466 | freeze.set(false); |
| 464 | }, [freeze, selectedThreadKey]); | 467 | }, [freeze, selectedThreadKey]); |
| 465 | 468 | ||
| 466 | useEffect(() => { | 469 | useEffect(() => { |
| 467 | if ( | 470 | if ( |
| 468 | anchorMessageId === null || | 471 | submittedMessageId === null || |
| 469 | lastScrolledAnchorMessageIdRef.current === anchorMessageId || | 472 | lastScrolledSubmittedMessageIdRef.current === submittedMessageId || |
| 470 | contentPresentationKind !== "ready" || | 473 | contentPresentationKind !== "ready" || |
| 471 | !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) | 474 | !selectedThreadFeed.some( |
| 475 | (entry) => entry.type === "message" && entry.id === submittedMessageId, | ||
| 476 | ) | ||
| 472 | ) { | 477 | ) { |
| 473 | return; | 478 | return; |
| 474 | } | 479 | } |
| ... | @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 478 | if (selectedThreadKeyRef.current !== targetThreadKey) { | 483 | if (selectedThreadKeyRef.current !== targetThreadKey) { |
| 479 | return; | 484 | return; |
| 480 | } | 485 | } |
| 481 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; | 486 | lastScrolledSubmittedMessageIdRef.current = submittedMessageId; |
| 482 | // Wait for the keyboard dismissal (started by blur() on send) to finish | 487 | // Wait for the keyboard dismissal (started by blur() on send) to finish |
| 483 | // before scrolling: scrollMessageToEnd freezes keyboard-driven inset | 488 | // before scrolling: scrollMessageToEnd freezes keyboard-driven inset |
| 484 | // updates while it runs, and a close event swallowed by that freeze | 489 | // updates while it runs, and a close event swallowed by that freeze |
| ... | @@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 488 | .then(() => { | 493 | .then(() => { |
| 489 | if ( | 494 | if ( |
| 490 | selectedThreadKeyRef.current !== targetThreadKey || | 495 | selectedThreadKeyRef.current !== targetThreadKey || |
| 491 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | 496 | lastScrolledSubmittedMessageIdRef.current !== submittedMessageId |
| 492 | ) { | 497 | ) { |
| 493 | return; | 498 | return; |
| 494 | } | 499 | } |
| ... | @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 497 | .catch(() => { | 502 | .catch(() => { |
| 498 | if ( | 503 | if ( |
| 499 | selectedThreadKeyRef.current !== targetThreadKey || | 504 | selectedThreadKeyRef.current !== targetThreadKey || |
| 500 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | 505 | lastScrolledSubmittedMessageIdRef.current !== submittedMessageId |
| 501 | ) { | 506 | ) { |
| 502 | return; | 507 | return; |
| 503 | } | 508 | } |
| 504 | lastScrolledAnchorMessageIdRef.current = null; | 509 | lastScrolledSubmittedMessageIdRef.current = null; |
| 505 | freeze.set(false); | 510 | freeze.set(false); |
| 506 | }); | 511 | }); |
| 507 | }); | 512 | }); |
| 508 | return () => cancelAnimationFrame(frame); | 513 | return () => cancelAnimationFrame(frame); |
| 509 | }, [ | 514 | }, [ |
| 510 | anchorMessageId, | 515 | submittedMessageId, |
| 511 | freeze, | 516 | freeze, |
| 512 | contentPresentationKind, | 517 | contentPresentationKind, |
| 513 | selectedThreadFeed, | 518 | selectedThreadFeed, |
| ... | @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 517 | 522 | ||
| 518 | const handleSendMessage = useCallback(async () => { | 523 | const handleSendMessage = useCallback(async () => { |
| 519 | const targetThreadKey = selectedThreadKey; | 524 | const targetThreadKey = selectedThreadKey; |
| 525 | const hasUserMessage = selectedThreadFeed.some( | ||
| 526 | (entry) => entry.type === "message" && entry.message.role === "user", | ||
| 527 | ); | ||
| 520 | const messageId = await props.onSendMessage(); | 528 | const messageId = await props.onSendMessage(); |
| 521 | if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { | 529 | if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { |
| 522 | return messageId; | 530 | return messageId; |
| 523 | } | 531 | } |
| 524 | 532 | ||
| 525 | setAnchorMessageId(messageId); | 533 | setSubmittedMessageId(messageId); |
| 534 | setAnchorMessageId( | ||
| 535 | resolveThreadFeedSubmissionAnchor({ | ||
| 536 | currentAnchorMessageId: anchorMessageId, | ||
| 537 | submittedMessageId: messageId, | ||
| 538 | hasStartedTurn: props.selectedThread.latestTurn !== null, | ||
| 539 | hasUserMessage, | ||
| 540 | queuedMessageCount: props.selectedThreadQueueCount, | ||
| 541 | }), | ||
| 542 | ); | ||
| 526 | composerEditorRef.current?.blur(); | 543 | composerEditorRef.current?.blur(); |
| 527 | return messageId; | 544 | return messageId; |
| 528 | }, [props.onSendMessage, selectedThreadKey]); | 545 | }, [ |
| 546 | anchorMessageId, | ||
| 547 | props.onSendMessage, | ||
| 548 | props.selectedThread.latestTurn, | ||
| 549 | props.selectedThreadQueueCount, | ||
| 550 | selectedThreadFeed, | ||
| 551 | selectedThreadKey, | ||
| 552 | ]); | ||
| 529 | 553 | ||
| 530 | const collapseComposer = useCallback(() => { | 554 | const collapseComposer = useCallback(() => { |
| 531 | composerEditorRef.current?.blur(); | 555 | composerEditorRef.current?.blur(); |
| ... | @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 595 | listRef={listRef} | 619 | listRef={listRef} |
| 596 | freeze={freeze} | 620 | freeze={freeze} |
| 597 | anchorMessageId={anchorMessageId} | 621 | anchorMessageId={anchorMessageId} |
| 622 | submittedMessageId={submittedMessageId} | ||
| 598 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} | 623 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} |
| 599 | contentTopInset={0} | 624 | contentTopInset={0} |
| 600 | contentBottomInset={estimatedOverlayHeight} | 625 | contentBottomInset={estimatedOverlayHeight} |
apps/mobile/src/features/threads/ThreadFeed.tsx+4-3| ... | @@ -151,6 +151,7 @@ export interface ThreadFeedProps { | ... | @@ -151,6 +151,7 @@ export interface ThreadFeedProps { |
| 151 | readonly listRef: RefObject<LegendListRef | null>; | 151 | readonly listRef: RefObject<LegendListRef | null>; |
| 152 | readonly freeze: SharedValue<boolean>; | 152 | readonly freeze: SharedValue<boolean>; |
| 153 | readonly anchorMessageId: MessageId | null; | 153 | readonly anchorMessageId: MessageId | null; |
| 154 | readonly submittedMessageId: MessageId | null; | ||
| 154 | readonly contentInsetEndAdjustment: SharedValue<number>; | 155 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 155 | readonly contentTopInset?: number; | 156 | readonly contentTopInset?: number; |
| 156 | readonly contentBottomInset?: number; | 157 | readonly contentBottomInset?: number; |
| ... | @@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1546 | transitionEndFollow({ type: "reset" }); | 1547 | transitionEndFollow({ type: "reset" }); |
| 1547 | }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); | 1548 | }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); |
| 1548 | useEffect(() => { | 1549 | useEffect(() => { |
| 1549 | if (props.anchorMessageId !== null) { | 1550 | if (props.submittedMessageId !== null) { |
| 1550 | clearUserScrollSettle(); | 1551 | clearUserScrollSettle(); |
| 1551 | userScrollSessionRef.current = false; | 1552 | userScrollSessionRef.current = false; |
| 1552 | transitionEndFollow({ type: "reset" }); | 1553 | transitionEndFollow({ type: "reset" }); |
| 1553 | } | 1554 | } |
| 1554 | }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); | 1555 | }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); |
| 1555 | 1556 | ||
| 1556 | const expandedWorkGroupIds = useMemo(() => { | 1557 | const expandedWorkGroupIds = useMemo(() => { |
| 1557 | const ids = new Set<string>(); | 1558 | const ids = new Set<string>(); |
| ... | @@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1600 | resolveChatListAnchoredEndSpace( | 1601 | resolveChatListAnchoredEndSpace( |
| 1601 | presentedFeed, | 1602 | presentedFeed, |
| 1602 | props.anchorMessageId, | 1603 | props.anchorMessageId, |
| 1603 | (entry) => (entry.type === "message" ? entry.id : null), | 1604 | (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), |
| 1604 | { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, | 1605 | { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, |
| 1605 | ), | 1606 | ), |
| 1606 | [presentedFeed, props.anchorMessageId, anchorTopInset], | 1607 | [presentedFeed, props.anchorMessageId, anchorTopInset], |
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+66-1| ... | @@ -1,6 +1,71 @@ | ... | @@ -1,6 +1,71 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; | 3 | import { |
| 4 | resolveThreadFeedLiveFollow, | ||
| 5 | resolveThreadFeedSubmissionAnchor, | ||
| 6 | } from "./thread-feed-live-follow"; | ||
| 7 | |||
| 8 | describe("resolveThreadFeedSubmissionAnchor", () => { | ||
| 9 | it("anchors the first user message in a thread", () => { | ||
| 10 | expect( | ||
| 11 | resolveThreadFeedSubmissionAnchor({ | ||
| 12 | currentAnchorMessageId: null, | ||
| 13 | submittedMessageId: "first-message", | ||
| 14 | hasStartedTurn: false, | ||
| 15 | hasUserMessage: false, | ||
| 16 | queuedMessageCount: 0, | ||
| 17 | }), | ||
| 18 | ).toBe("first-message"); | ||
| 19 | }); | ||
| 20 | |||
| 21 | it("preserves the first-message anchor when another message is queued", () => { | ||
| 22 | expect( | ||
| 23 | resolveThreadFeedSubmissionAnchor({ | ||
| 24 | currentAnchorMessageId: "first-message", | ||
| 25 | submittedMessageId: "second-message", | ||
| 26 | hasStartedTurn: false, | ||
| 27 | hasUserMessage: false, | ||
| 28 | queuedMessageCount: 1, | ||
| 29 | }), | ||
| 30 | ).toBe("first-message"); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("preserves the first-message anchor after its outbox entry drains", () => { | ||
| 34 | expect( | ||
| 35 | resolveThreadFeedSubmissionAnchor({ | ||
| 36 | currentAnchorMessageId: "first-message", | ||
| 37 | submittedMessageId: "second-message", | ||
| 38 | hasStartedTurn: false, | ||
| 39 | hasUserMessage: false, | ||
| 40 | queuedMessageCount: 0, | ||
| 41 | }), | ||
| 42 | ).toBe("first-message"); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("does not anchor a follow-up after a user message appears", () => { | ||
| 46 | expect( | ||
| 47 | resolveThreadFeedSubmissionAnchor({ | ||
| 48 | currentAnchorMessageId: "first-message", | ||
| 49 | submittedMessageId: "second-message", | ||
| 50 | hasStartedTurn: false, | ||
| 51 | hasUserMessage: true, | ||
| 52 | queuedMessageCount: 0, | ||
| 53 | }), | ||
| 54 | ).toBeNull(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("does not anchor a thread that has already started a turn", () => { | ||
| 58 | expect( | ||
| 59 | resolveThreadFeedSubmissionAnchor({ | ||
| 60 | currentAnchorMessageId: null, | ||
| 61 | submittedMessageId: "second-message", | ||
| 62 | hasStartedTurn: true, | ||
| 63 | hasUserMessage: false, | ||
| 64 | queuedMessageCount: 0, | ||
| 65 | }), | ||
| 66 | ).toBeNull(); | ||
| 67 | }); | ||
| 68 | }); | ||
| 4 | 69 | ||
| 5 | describe("resolveThreadFeedLiveFollow", () => { | 70 | describe("resolveThreadFeedLiveFollow", () => { |
| 6 | it("pauses immediately when the user starts scrolling", () => { | 71 | it("pauses immediately when the user starts scrolling", () => { |
apps/mobile/src/features/threads/thread-feed-live-follow.ts+18| ... | @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = | ... | @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = |
| 12 | readonly userScrollSessionActive: boolean; | 12 | readonly userScrollSessionActive: boolean; |
| 13 | }; | 13 | }; |
| 14 | 14 | ||
| 15 | export function resolveThreadFeedSubmissionAnchor<AnchorId>(input: { | ||
| 16 | readonly currentAnchorMessageId: AnchorId | null; | ||
| 17 | readonly submittedMessageId: AnchorId; | ||
| 18 | readonly hasStartedTurn: boolean; | ||
| 19 | readonly hasUserMessage: boolean; | ||
| 20 | readonly queuedMessageCount: number; | ||
| 21 | }): AnchorId | null { | ||
| 22 | if (input.hasStartedTurn || input.hasUserMessage) { | ||
| 23 | return null; | ||
| 24 | } | ||
| 25 | |||
| 26 | if (input.currentAnchorMessageId !== null) { | ||
| 27 | return input.currentAnchorMessageId; | ||
| 28 | } | ||
| 29 | |||
| 30 | return input.queuedMessageCount > 0 ? null : input.submittedMessageId; | ||
| 31 | } | ||
| 32 | |||
| 15 | export function resolveThreadFeedLiveFollow( | 33 | export function resolveThreadFeedLiveFollow( |
| 16 | current: boolean, | 34 | current: boolean, |
| 17 | event: ThreadFeedLiveFollowEvent, | 35 | event: ThreadFeedLiveFollowEvent, |
apps/web/src/components/ChatView.logic.test.ts+23| ... | @@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { | ... | @@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 638 | ).toBe(false); | 638 | ).toBe(false); |
| 639 | }); | 639 | }); |
| 640 | 640 | ||
| 641 | it("keeps a follow-up active while its provider session is starting", () => { | ||
| 642 | const localDispatch = createLocalDispatchSnapshot( | ||
| 643 | makeThread({ latestTurn: completedTurn, session: readySession }), | ||
| 644 | ); | ||
| 645 | |||
| 646 | expect( | ||
| 647 | hasServerAcknowledgedLocalDispatch({ | ||
| 648 | localDispatch, | ||
| 649 | phase: "connecting", | ||
| 650 | latestTurn: completedTurn, | ||
| 651 | latestUserMessageId: MessageId.make("message-followup"), | ||
| 652 | session: { | ||
| 653 | ...readySession, | ||
| 654 | status: "starting", | ||
| 655 | updatedAt: "2026-03-29T00:01:00.000Z", | ||
| 656 | }, | ||
| 657 | hasPendingApproval: false, | ||
| 658 | hasPendingUserInput: false, | ||
| 659 | threadError: null, | ||
| 660 | }), | ||
| 661 | ).toBe(false); | ||
| 662 | }); | ||
| 663 | |||
| 641 | it("acknowledges a settled newer turn", () => { | 664 | it("acknowledges a settled newer turn", () => { |
| 642 | const localDispatch = createLocalDispatchSnapshot( | 665 | const localDispatch = createLocalDispatchSnapshot( |
| 643 | makeThread({ latestTurn: completedTurn, session: readySession }), | 666 | makeThread({ latestTurn: completedTurn, session: readySession }), |
apps/web/src/components/ChatView.logic.ts+3| ... | @@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { | ... | @@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 601 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { | 601 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { |
| 602 | return true; | 602 | return true; |
| 603 | } | 603 | } |
| 604 | if (input.phase === "connecting") { | ||
| 605 | return false; | ||
| 606 | } | ||
| 604 | 607 | ||
| 605 | const latestTurn = input.latestTurn ?? null; | 608 | const latestTurn = input.latestTurn ?? null; |
| 606 | const session = input.session ?? null; | 609 | const session = input.session ?? null; |
apps/web/src/components/ChatView.tsx+21-28| ... | @@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 5297 | sizeBytes: image.sizeBytes, | 5297 | sizeBytes: image.sizeBytes, |
| 5298 | previewUrl: image.previewUrl, | 5298 | previewUrl: image.previewUrl, |
| 5299 | })); | 5299 | })); |
| 5300 | // Sending always returns to the live edge. The new row becomes the | 5300 | const shouldAnchorFirstMessage = |
| 5301 | // anchored end-space target so it lands near the top while the response | 5301 | activeThread.latestTurn === null && |
| 5302 | // streams into the reserved space below it. | 5302 | !timelineMessages.some((message) => message.role === "user"); |
| 5303 | isAtEndRef.current = true; | 5303 | if (shouldAnchorFirstMessage) { |
| 5304 | timelineScrollModeRef.current = "anchoring-new-turn"; | 5304 | isAtEndRef.current = true; |
| 5305 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | 5305 | timelineScrollModeRef.current = "anchoring-new-turn"; |
| 5306 | setTimelineLiveFollowEnabled(true); | 5306 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; |
| 5307 | pendingTimelineAnchorRef.current = messageIdForSend; | 5307 | setTimelineLiveFollowEnabled(true); |
| 5308 | activeTimelineAnchorIndexRef.current = null; | 5308 | pendingTimelineAnchorRef.current = messageIdForSend; |
| 5309 | showScrollDebouncer.current.cancel(); | 5309 | activeTimelineAnchorIndexRef.current = null; |
| 5310 | setShowScrollToBottom(false); | 5310 | showScrollDebouncer.current.cancel(); |
| 5311 | setTimelineAnchor({ | 5311 | setShowScrollToBottom(false); |
| 5312 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | 5312 | setTimelineAnchor({ |
| 5313 | messageId: messageIdForSend, | 5313 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), |
| 5314 | }); | 5314 | messageId: messageIdForSend, |
| 5315 | }); | ||
| 5316 | } else { | ||
| 5317 | scrollToEnd(); | ||
| 5318 | } | ||
| 5315 | setOptimisticUserMessages((existing) => [ | 5319 | setOptimisticUserMessages((existing) => [ |
| 5316 | ...existing, | 5320 | ...existing, |
| 5317 | { | 5321 | { |
| ... | @@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5815 | beginLocalDispatch({ preparingWorktree: false }); | 5819 | beginLocalDispatch({ preparingWorktree: false }); |
| 5816 | setThreadError(threadIdForSend, null); | 5820 | setThreadError(threadIdForSend, null); |
| 5817 | 5821 | ||
| 5818 | // Position this sent row once LegendList has measured the anchored tail. | 5822 | scrollToEnd(); |
| 5819 | isAtEndRef.current = true; | ||
| 5820 | timelineScrollModeRef.current = "anchoring-new-turn"; | ||
| 5821 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | ||
| 5822 | setTimelineLiveFollowEnabled(true); | ||
| 5823 | pendingTimelineAnchorRef.current = messageIdForSend; | ||
| 5824 | activeTimelineAnchorIndexRef.current = null; | ||
| 5825 | showScrollDebouncer.current.cancel(); | ||
| 5826 | setShowScrollToBottom(false); | ||
| 5827 | setTimelineAnchor({ | ||
| 5828 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | ||
| 5829 | messageId: messageIdForSend, | ||
| 5830 | }); | ||
| 5831 | 5823 | ||
| 5832 | setOptimisticUserMessages((existing) => [ | 5824 | setOptimisticUserMessages((existing) => [ |
| 5833 | ...existing, | 5825 | ...existing, |
| ... | @@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5922 | persistThreadSettingsForNextTurn, | 5914 | persistThreadSettingsForNextTurn, |
| 5923 | resetLocalDispatch, | 5915 | resetLocalDispatch, |
| 5924 | runtimeMode, | 5916 | runtimeMode, |
| 5917 | scrollToEnd, | ||
| 5925 | setComposerDraftInteractionMode, | 5918 | setComposerDraftInteractionMode, |
| 5926 | setThreadError, | 5919 | setThreadError, |
| 5927 | startThreadTurn, | 5920 | startThreadTurn, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-12| ... | @@ -434,15 +434,12 @@ describe("MessagesTimeline", () => { | ... | @@ -434,15 +434,12 @@ describe("MessagesTimeline", () => { |
| 434 | expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); | 434 | expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); |
| 435 | }); | 435 | }); |
| 436 | 436 | ||
| 437 | it("anchors a sent attachment message using its measured height", () => { | 437 | it("anchors the first user message using its measured height", () => { |
| 438 | const onAnchorReady = vi.fn(); | 438 | const onAnchorReady = vi.fn(); |
| 439 | const firstEntry = buildUserTimelineEntry("First prompt."); | 439 | const firstEntry = { |
| 440 | const secondEntry = { | 440 | ...buildUserTimelineEntry("First prompt."), |
| 441 | ...buildUserTimelineEntry("Newest prompt."), | ||
| 442 | id: "entry-2", | ||
| 443 | message: { | 441 | message: { |
| 444 | ...buildUserTimelineEntry("Newest prompt.").message, | 442 | ...buildUserTimelineEntry("First prompt.").message, |
| 445 | id: MessageId.make("message-2"), | ||
| 446 | attachments: [ | 443 | attachments: [ |
| 447 | { | 444 | { |
| 448 | type: "image" as const, | 445 | type: "image" as const, |
| ... | @@ -458,14 +455,14 @@ describe("MessagesTimeline", () => { | ... | @@ -458,14 +455,14 @@ describe("MessagesTimeline", () => { |
| 458 | const markup = renderToStaticMarkup( | 455 | const markup = renderToStaticMarkup( |
| 459 | <MessagesTimeline | 456 | <MessagesTimeline |
| 460 | {...buildProps()} | 457 | {...buildProps()} |
| 461 | anchorMessageId={secondEntry.message.id} | 458 | anchorMessageId={firstEntry.message.id} |
| 462 | onAnchorReady={onAnchorReady} | 459 | onAnchorReady={onAnchorReady} |
| 463 | contentInsetEndAdjustment={144} | 460 | contentInsetEndAdjustment={144} |
| 464 | timelineEntries={[firstEntry, secondEntry]} | 461 | timelineEntries={[firstEntry]} |
| 465 | />, | 462 | />, |
| 466 | ); | 463 | ); |
| 467 | 464 | ||
| 468 | expect(markup).toContain('data-anchor-index="1"'); | 465 | expect(markup).toContain('data-anchor-index="0"'); |
| 469 | expect(markup).toContain('data-anchor-offset="16"'); | 466 | expect(markup).toContain('data-anchor-offset="16"'); |
| 470 | expect(markup).toContain('data-anchor-on-ready="true"'); | 467 | expect(markup).toContain('data-anchor-on-ready="true"'); |
| 471 | expect(markup).not.toContain("data-anchor-max-size="); | 468 | expect(markup).not.toContain("data-anchor-max-size="); |
| ... | @@ -477,7 +474,32 @@ describe("MessagesTimeline", () => { | ... | @@ -477,7 +474,32 @@ describe("MessagesTimeline", () => { |
| 477 | expect(markup).toContain('data-maintain-visible-content-position-size="true"'); | 474 | expect(markup).toContain('data-maintain-visible-content-position-size="true"'); |
| 478 | expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); | 475 | expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); |
| 479 | expect(onAnchorReady).toHaveBeenCalledOnce(); | 476 | expect(onAnchorReady).toHaveBeenCalledOnce(); |
| 480 | expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); | 477 | expect(onAnchorReady).toHaveBeenCalledWith(firstEntry.message.id, 0); |
| 478 | }); | ||
| 479 | |||
| 480 | it("does not reserve end space for a follow-up user message", () => { | ||
| 481 | const onAnchorReady = vi.fn(); | ||
| 482 | const firstEntry = buildUserTimelineEntry("First prompt."); | ||
| 483 | const secondEntry = { | ||
| 484 | ...buildUserTimelineEntry("Newest prompt."), | ||
| 485 | id: "entry-2", | ||
| 486 | message: { | ||
| 487 | ...buildUserTimelineEntry("Newest prompt.").message, | ||
| 488 | id: MessageId.make("message-2"), | ||
| 489 | }, | ||
| 490 | }; | ||
| 491 | const markup = renderToStaticMarkup( | ||
| 492 | <MessagesTimeline | ||
| 493 | {...buildProps()} | ||
| 494 | anchorMessageId={secondEntry.message.id} | ||
| 495 | onAnchorReady={onAnchorReady} | ||
| 496 | timelineEntries={[firstEntry, secondEntry]} | ||
| 497 | />, | ||
| 498 | ); | ||
| 499 | |||
| 500 | expect(markup).not.toContain("data-anchor-index="); | ||
| 501 | expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); | ||
| 502 | expect(onAnchorReady).not.toHaveBeenCalled(); | ||
| 481 | }); | 503 | }); |
| 482 | 504 | ||
| 483 | it("hands end-following back to the list once the send anchor is released", () => { | 505 | it("hands end-following back to the list once the send anchor is released", () => { |
| ... | @@ -498,7 +520,7 @@ describe("MessagesTimeline", () => { | ... | @@ -498,7 +520,7 @@ describe("MessagesTimeline", () => { |
| 498 | renderToStaticMarkup( | 520 | renderToStaticMarkup( |
| 499 | <MessagesTimeline | 521 | <MessagesTimeline |
| 500 | {...buildProps()} | 522 | {...buildProps()} |
| 501 | anchorMessageId={secondEntry.message.id} | 523 | anchorMessageId={firstEntry.message.id} |
| 502 | timelineEntries={timelineEntries} | 524 | timelineEntries={timelineEntries} |
| 503 | />, | 525 | />, |
| 504 | ), | 526 | ), |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | @@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -446,7 +446,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 446 | ); | 446 | ); |
| 447 | const anchoredEndSpace = useMemo(() => { | 447 | const anchoredEndSpace = useMemo(() => { |
| 448 | const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => | 448 | const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => |
| 449 | row.kind === "message" ? row.message.id : null, | 449 | row.kind === "message" && row.message.role === "user" ? row.message.id : null, |
| 450 | ); | 450 | ); |
| 451 | return config ? { ...config, onReady: handleAnchorReady } : undefined; | 451 | return config ? { ...config, onReady: handleAnchorReady } : undefined; |
| 452 | }, [anchorMessageId, handleAnchorReady, rows]); | 452 | }, [anchorMessageId, handleAnchorReady, rows]); |
packages/shared/src/chatList.test.ts+16-5| ... | @@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [ | ... | @@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [ |
| 16 | const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); | 16 | const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); |
| 17 | 17 | ||
| 18 | describe("resolveChatListAnchoredEndSpace", () => { | 18 | describe("resolveChatListAnchoredEndSpace", () => { |
| 19 | it("anchors the matching row using its measured height", () => { | 19 | it("anchors only the first eligible row", () => { |
| 20 | expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ | 20 | expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({ |
| 21 | anchorIndex: 2, | 21 | anchorIndex: 0, |
| 22 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, | 22 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, |
| 23 | }); | 23 | }); |
| 24 | }); | 24 | }); |
| 25 | 25 | ||
| 26 | it("allows a surface to keep the anchor below its own header", () => { | 26 | it("allows a surface to keep the anchor below its own header", () => { |
| 27 | expect( | 27 | expect( |
| 28 | resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, { | 28 | resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, { |
| 29 | anchorOffset: 132, | 29 | anchorOffset: 132, |
| 30 | }), | 30 | }), |
| 31 | ).toEqual({ | 31 | ).toEqual({ |
| 32 | anchorIndex: 2, | 32 | anchorIndex: 0, |
| 33 | anchorOffset: 132, | 33 | anchorOffset: 132, |
| 34 | }); | 34 | }); |
| 35 | }); | 35 | }); |
| 36 | 36 | ||
| 37 | it("does not reserve end space for later eligible rows", () => { | ||
| 38 | expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toBeUndefined(); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("skips ineligible rows before the first anchor", () => { | ||
| 42 | expect(resolveChatListAnchoredEndSpace(rows.slice(1), "latest", getAnchorId)).toEqual({ | ||
| 43 | anchorIndex: 1, | ||
| 44 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, | ||
| 45 | }); | ||
| 46 | }); | ||
| 47 | |||
| 37 | it("ignores ineligible rows and missing anchors", () => { | 48 | it("ignores ineligible rows and missing anchors", () => { |
| 38 | expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); | 49 | expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); |
| 39 | expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); | 50 | expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); |
packages/shared/src/chatList.ts+15-6| ... | @@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>( | ... | @@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>( |
| 19 | return undefined; | 19 | return undefined; |
| 20 | } | 20 | } |
| 21 | 21 | ||
| 22 | for (let index = items.length - 1; index >= 0; index -= 1) { | 22 | for (let index = 0; index < items.length; index += 1) { |
| 23 | const item = items[index]; | 23 | const item = items[index]; |
| 24 | if (item !== undefined && getAnchorId(item) === anchorId) { | 24 | if (item === undefined) { |
| 25 | return { | 25 | continue; |
| 26 | anchorIndex: index, | ||
| 27 | anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, | ||
| 28 | }; | ||
| 29 | } | 26 | } |
| 27 | |||
| 28 | const itemAnchorId = getAnchorId(item); | ||
| 29 | if (itemAnchorId === null) { | ||
| 30 | continue; | ||
| 31 | } | ||
| 32 | |||
| 33 | return itemAnchorId === anchorId | ||
| 34 | ? { | ||
| 35 | anchorIndex: index, | ||
| 36 | anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, | ||
| 37 | } | ||
| 38 | : undefined; | ||
| 30 | } | 39 | } |
| 31 | 40 | ||
| 32 | return undefined; | 41 | return undefined; |