| 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 | 79 | } from "./ThreadComposer"; |
| 80 | 80 | import { ThreadFeed } from "./ThreadFeed"; |
| 81 | 81 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 82 | import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; | |
| 82 | 83 | |
| 83 | 84 | export interface ThreadDetailScreenProps { |
| 84 | 85 | readonly selectedThread: OrchestrationThreadShell; |
| ... | ... | @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 257 | 258 | const listRef = useRef<LegendListRef>(null); |
| 258 | 259 | const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); |
| 259 | 260 | const selectedThreadKeyRef = useRef(selectedThreadKey); |
| 260 | const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null); | |
| 261 | const lastScrolledSubmittedMessageIdRef = useRef<MessageId | null>(null); | |
| 261 | 262 | const [composerExpanded, setComposerExpanded] = useState(false); |
| 262 | 263 | const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null); |
| 264 | const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(null); | |
| 263 | 265 | const [endFollowEnabled, setEndFollowEnabled] = useState(true); |
| 264 | 266 | // Android keys the safe-area padding on keyboard visibility (#5988): the |
| 265 | 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 | 460 | |
| 459 | 461 | useEffect(() => { |
| 460 | 462 | setAnchorMessageId(null); |
| 461 | lastScrolledAnchorMessageIdRef.current = null; | |
| 463 | setSubmittedMessageId(null); | |
| 464 | lastScrolledSubmittedMessageIdRef.current = null; | |
| 462 | 465 | setEndFollowEnabled(true); |
| 463 | 466 | freeze.set(false); |
| 464 | 467 | }, [freeze, selectedThreadKey]); |
| 465 | 468 | |
| 466 | 469 | useEffect(() => { |
| 467 | 470 | if ( |
| 468 | anchorMessageId === null || | |
| 469 | lastScrolledAnchorMessageIdRef.current === anchorMessageId || | |
| 471 | submittedMessageId === null || | |
| 472 | lastScrolledSubmittedMessageIdRef.current === submittedMessageId || | |
| 470 | 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 | 478 | return; |
| 474 | 479 | } |
| ... | ... | @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 478 | 483 | if (selectedThreadKeyRef.current !== targetThreadKey) { |
| 479 | 484 | return; |
| 480 | 485 | } |
| 481 | lastScrolledAnchorMessageIdRef.current = anchorMessageId; | |
| 486 | lastScrolledSubmittedMessageIdRef.current = submittedMessageId; | |
| 482 | 487 | // Wait for the keyboard dismissal (started by blur() on send) to finish |
| 483 | 488 | // before scrolling: scrollMessageToEnd freezes keyboard-driven inset |
| 484 | 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 | 493 | .then(() => { |
| 489 | 494 | if ( |
| 490 | 495 | selectedThreadKeyRef.current !== targetThreadKey || |
| 491 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | |
| 496 | lastScrolledSubmittedMessageIdRef.current !== submittedMessageId | |
| 492 | 497 | ) { |
| 493 | 498 | return; |
| 494 | 499 | } |
| ... | ... | @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 497 | 502 | .catch(() => { |
| 498 | 503 | if ( |
| 499 | 504 | selectedThreadKeyRef.current !== targetThreadKey || |
| 500 | lastScrolledAnchorMessageIdRef.current !== anchorMessageId | |
| 505 | lastScrolledSubmittedMessageIdRef.current !== submittedMessageId | |
| 501 | 506 | ) { |
| 502 | 507 | return; |
| 503 | 508 | } |
| 504 | lastScrolledAnchorMessageIdRef.current = null; | |
| 509 | lastScrolledSubmittedMessageIdRef.current = null; | |
| 505 | 510 | freeze.set(false); |
| 506 | 511 | }); |
| 507 | 512 | }); |
| 508 | 513 | return () => cancelAnimationFrame(frame); |
| 509 | 514 | }, [ |
| 510 | anchorMessageId, | |
| 515 | submittedMessageId, | |
| 511 | 516 | freeze, |
| 512 | 517 | contentPresentationKind, |
| 513 | 518 | selectedThreadFeed, |
| ... | ... | @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 517 | 522 | |
| 518 | 523 | const handleSendMessage = useCallback(async () => { |
| 519 | 524 | const targetThreadKey = selectedThreadKey; |
| 525 | const hasUserMessage = selectedThreadFeed.some( | |
| 526 | (entry) => entry.type === "message" && entry.message.role === "user", | |
| 527 | ); | |
| 520 | 528 | const messageId = await props.onSendMessage(); |
| 521 | 529 | if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { |
| 522 | 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 | 543 | composerEditorRef.current?.blur(); |
| 527 | 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 | 554 | const collapseComposer = useCallback(() => { |
| 531 | 555 | composerEditorRef.current?.blur(); |
| ... | ... | @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 595 | 619 | listRef={listRef} |
| 596 | 620 | freeze={freeze} |
| 597 | 621 | anchorMessageId={anchorMessageId} |
| 622 | submittedMessageId={submittedMessageId} | |
| 598 | 623 | contentInsetEndAdjustment={combinedContentInsetEndAdjustment} |
| 599 | 624 | contentTopInset={0} |
| 600 | 625 | contentBottomInset={estimatedOverlayHeight} |
apps/mobile/src/features/threads/ThreadFeed.tsx+4-3| ... | ... | @@ -151,6 +151,7 @@ export interface ThreadFeedProps { |
| 151 | 151 | readonly listRef: RefObject<LegendListRef | null>; |
| 152 | 152 | readonly freeze: SharedValue<boolean>; |
| 153 | 153 | readonly anchorMessageId: MessageId | null; |
| 154 | readonly submittedMessageId: MessageId | null; | |
| 154 | 155 | readonly contentInsetEndAdjustment: SharedValue<number>; |
| 155 | 156 | readonly contentTopInset?: number; |
| 156 | 157 | readonly contentBottomInset?: number; |
| ... | ... | @@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1546 | 1547 | transitionEndFollow({ type: "reset" }); |
| 1547 | 1548 | }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); |
| 1548 | 1549 | useEffect(() => { |
| 1549 | if (props.anchorMessageId !== null) { | |
| 1550 | if (props.submittedMessageId !== null) { | |
| 1550 | 1551 | clearUserScrollSettle(); |
| 1551 | 1552 | userScrollSessionRef.current = false; |
| 1552 | 1553 | transitionEndFollow({ type: "reset" }); |
| 1553 | 1554 | } |
| 1554 | }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); | |
| 1555 | }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); | |
| 1555 | 1556 | |
| 1556 | 1557 | const expandedWorkGroupIds = useMemo(() => { |
| 1557 | 1558 | const ids = new Set<string>(); |
| ... | ... | @@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1600 | 1601 | resolveChatListAnchoredEndSpace( |
| 1601 | 1602 | presentedFeed, |
| 1602 | 1603 | props.anchorMessageId, |
| 1603 | (entry) => (entry.type === "message" ? entry.id : null), | |
| 1604 | (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), | |
| 1604 | 1605 | { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, |
| 1605 | 1606 | ), |
| 1606 | 1607 | [presentedFeed, props.anchorMessageId, anchorTopInset], |
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts+66-1| ... | ... | @@ -1,6 +1,71 @@ |
| 1 | 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 | 70 | describe("resolveThreadFeedLiveFollow", () => { |
| 6 | 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 | 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 | 33 | export function resolveThreadFeedLiveFollow( |
| 16 | 34 | current: boolean, |
| 17 | 35 | event: ThreadFeedLiveFollowEvent, |
apps/web/src/components/ChatView.logic.test.ts+23| ... | ... | @@ -638,6 +638,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { |
| 638 | 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 | 664 | it("acknowledges a settled newer turn", () => { |
| 642 | 665 | const localDispatch = createLocalDispatchSnapshot( |
| 643 | 666 | makeThread({ latestTurn: completedTurn, session: readySession }), |
apps/web/src/components/ChatView.logic.ts+3| ... | ... | @@ -601,6 +601,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { |
| 601 | 601 | if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { |
| 602 | 602 | return true; |
| 603 | 603 | } |
| 604 | if (input.phase === "connecting") { | |
| 605 | return false; | |
| 606 | } | |
| 604 | 607 | |
| 605 | 608 | const latestTurn = input.latestTurn ?? null; |
| 606 | 609 | const session = input.session ?? null; |
apps/web/src/components/ChatView.tsx+21-28| ... | ... | @@ -5297,21 +5297,25 @@ function ChatViewContent(props: ChatViewProps) { |
| 5297 | 5297 | sizeBytes: image.sizeBytes, |
| 5298 | 5298 | previewUrl: image.previewUrl, |
| 5299 | 5299 | })); |
| 5300 | // Sending always returns to the live edge. The new row becomes the | |
| 5301 | // anchored end-space target so it lands near the top while the response | |
| 5302 | // streams into the reserved space below it. | |
| 5303 | isAtEndRef.current = true; | |
| 5304 | timelineScrollModeRef.current = "anchoring-new-turn"; | |
| 5305 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | |
| 5306 | setTimelineLiveFollowEnabled(true); | |
| 5307 | pendingTimelineAnchorRef.current = messageIdForSend; | |
| 5308 | activeTimelineAnchorIndexRef.current = null; | |
| 5309 | showScrollDebouncer.current.cancel(); | |
| 5310 | setShowScrollToBottom(false); | |
| 5311 | setTimelineAnchor({ | |
| 5312 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | |
| 5313 | messageId: messageIdForSend, | |
| 5314 | }); | |
| 5300 | const shouldAnchorFirstMessage = | |
| 5301 | activeThread.latestTurn === null && | |
| 5302 | !timelineMessages.some((message) => message.role === "user"); | |
| 5303 | if (shouldAnchorFirstMessage) { | |
| 5304 | isAtEndRef.current = true; | |
| 5305 | timelineScrollModeRef.current = "anchoring-new-turn"; | |
| 5306 | liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; | |
| 5307 | setTimelineLiveFollowEnabled(true); | |
| 5308 | pendingTimelineAnchorRef.current = messageIdForSend; | |
| 5309 | activeTimelineAnchorIndexRef.current = null; | |
| 5310 | showScrollDebouncer.current.cancel(); | |
| 5311 | setShowScrollToBottom(false); | |
| 5312 | setTimelineAnchor({ | |
| 5313 | threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), | |
| 5314 | messageId: messageIdForSend, | |
| 5315 | }); | |
| 5316 | } else { | |
| 5317 | scrollToEnd(); | |
| 5318 | } | |
| 5315 | 5319 | setOptimisticUserMessages((existing) => [ |
| 5316 | 5320 | ...existing, |
| 5317 | 5321 | { |
| ... | ... | @@ -5815,19 +5819,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5815 | 5819 | beginLocalDispatch({ preparingWorktree: false }); |
| 5816 | 5820 | setThreadError(threadIdForSend, null); |
| 5817 | 5821 | |
| 5818 | // Position this sent row once LegendList has measured the anchored tail. | |
| 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 | }); | |
| 5822 | scrollToEnd(); | |
| 5831 | 5823 | |
| 5832 | 5824 | setOptimisticUserMessages((existing) => [ |
| 5833 | 5825 | ...existing, |
| ... | ... | @@ -5922,6 +5914,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5922 | 5914 | persistThreadSettingsForNextTurn, |
| 5923 | 5915 | resetLocalDispatch, |
| 5924 | 5916 | runtimeMode, |
| 5917 | scrollToEnd, | |
| 5925 | 5918 | setComposerDraftInteractionMode, |
| 5926 | 5919 | setThreadError, |
| 5927 | 5920 | startThreadTurn, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+34-12| ... | ... | @@ -434,15 +434,12 @@ describe("MessagesTimeline", () => { |
| 434 | 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 | 438 | const onAnchorReady = vi.fn(); |
| 439 | const firstEntry = buildUserTimelineEntry("First prompt."); | |
| 440 | const secondEntry = { | |
| 441 | ...buildUserTimelineEntry("Newest prompt."), | |
| 442 | id: "entry-2", | |
| 439 | const firstEntry = { | |
| 440 | ...buildUserTimelineEntry("First prompt."), | |
| 443 | 441 | message: { |
| 444 | ...buildUserTimelineEntry("Newest prompt.").message, | |
| 445 | id: MessageId.make("message-2"), | |
| 442 | ...buildUserTimelineEntry("First prompt.").message, | |
| 446 | 443 | attachments: [ |
| 447 | 444 | { |
| 448 | 445 | type: "image" as const, |
| ... | ... | @@ -458,14 +455,14 @@ describe("MessagesTimeline", () => { |
| 458 | 455 | const markup = renderToStaticMarkup( |
| 459 | 456 | <MessagesTimeline |
| 460 | 457 | {...buildProps()} |
| 461 | anchorMessageId={secondEntry.message.id} | |
| 458 | anchorMessageId={firstEntry.message.id} | |
| 462 | 459 | onAnchorReady={onAnchorReady} |
| 463 | 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 | 466 | expect(markup).toContain('data-anchor-offset="16"'); |
| 470 | 467 | expect(markup).toContain('data-anchor-on-ready="true"'); |
| 471 | 468 | expect(markup).not.toContain("data-anchor-max-size="); |
| ... | ... | @@ -477,7 +474,32 @@ describe("MessagesTimeline", () => { |
| 477 | 474 | expect(markup).toContain('data-maintain-visible-content-position-size="true"'); |
| 478 | 475 | expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); |
| 479 | 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 | 505 | it("hands end-following back to the list once the send anchor is released", () => { |
| ... | ... | @@ -498,7 +520,7 @@ describe("MessagesTimeline", () => { |
| 498 | 520 | renderToStaticMarkup( |
| 499 | 521 | <MessagesTimeline |
| 500 | 522 | {...buildProps()} |
| 501 | anchorMessageId={secondEntry.message.id} | |
| 523 | anchorMessageId={firstEntry.message.id} | |
| 502 | 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 | 446 | ); |
| 447 | 447 | const anchoredEndSpace = useMemo(() => { |
| 448 | 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 | 451 | return config ? { ...config, onReady: handleAnchorReady } : undefined; |
| 452 | 452 | }, [anchorMessageId, handleAnchorReady, rows]); |
packages/shared/src/chatList.test.ts+16-5| ... | ... | @@ -16,24 +16,35 @@ const rows: ReadonlyArray<Row> = [ |
| 16 | 16 | const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); |
| 17 | 17 | |
| 18 | 18 | describe("resolveChatListAnchoredEndSpace", () => { |
| 19 | it("anchors the matching row using its measured height", () => { | |
| 20 | expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ | |
| 21 | anchorIndex: 2, | |
| 19 | it("anchors only the first eligible row", () => { | |
| 20 | expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({ | |
| 21 | anchorIndex: 0, | |
| 22 | 22 | anchorOffset: CHAT_LIST_ANCHOR_OFFSET, |
| 23 | 23 | }); |
| 24 | 24 | }); |
| 25 | 25 | |
| 26 | 26 | it("allows a surface to keep the anchor below its own header", () => { |
| 27 | 27 | expect( |
| 28 | resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId, { | |
| 28 | resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, { | |
| 29 | 29 | anchorOffset: 132, |
| 30 | 30 | }), |
| 31 | 31 | ).toEqual({ |
| 32 | anchorIndex: 2, | |
| 32 | anchorIndex: 0, | |
| 33 | 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 | 48 | it("ignores ineligible rows and missing anchors", () => { |
| 38 | 49 | expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); |
| 39 | 50 | expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); |
packages/shared/src/chatList.ts+15-6| ... | ... | @@ -19,14 +19,23 @@ export function resolveChatListAnchoredEndSpace<Item, AnchorId>( |
| 19 | 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 | 23 | const item = items[index]; |
| 24 | if (item !== undefined && getAnchorId(item) === anchorId) { | |
| 25 | return { | |
| 26 | anchorIndex: index, | |
| 27 | anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, | |
| 28 | }; | |
| 24 | if (item === undefined) { | |
| 25 | continue; | |
| 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 | 41 | return undefined; |