| author | |
| committer | |
| log | b792ed9f7b9c7c6386a14a1697c4393567812546 |
| tree | e3a7152594fd3ddae0bb9a5f737432379c97c28d |
| parent | 2e66b1fdfc63d39822cd58948bcf43e8fbc239b6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>5 files changed, 133 insertions(+), 202 deletions(-)
apps/web/package.json+1-1| ... | ... | @@ -21,7 +21,7 @@ |
| 21 | 21 | "@dnd-kit/utilities": "^3.2.2", |
| 22 | 22 | "@effect/atom-react": "catalog:", |
| 23 | 23 | "@formkit/auto-animate": "^0.9.0", |
| 24 | "@legendapp/list": "3.2.0", | |
| 24 | "@legendapp/list": "3.3.3", | |
| 25 | 25 | "@lexical/react": "^0.41.0", |
| 26 | 26 | "@pierre/diffs": "catalog:", |
| 27 | 27 | "@pierre/trees": "1.0.0-beta.4", |
apps/web/src/components/ChatView.tsx+32-104| ... | ... | @@ -3532,12 +3532,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 3532 | 3532 | const activeTimelineAnchorIndexRef = useRef<number | null>(null); |
| 3533 | 3533 | const anchorUserScrollGenerationRef = useRef(0); |
| 3534 | 3534 | const liveFollowUserScrollGenerationRef = useRef<number | null>(0); |
| 3535 | const pendingAnchorScrollRestoreRef = useRef<{ | |
| 3536 | readonly messageId: MessageId; | |
| 3537 | readonly offset: number; | |
| 3538 | readonly userScrollGeneration: number; | |
| 3539 | } | null>(null); | |
| 3540 | const anchorScrollRestoreFrameRef = useRef<number | null>(null); | |
| 3535 | // Manual navigation stops live-follow without removing anchored end space. | |
| 3536 | // Collapsing that space during a gesture clamps the viewport back to the end. | |
| 3541 | 3537 | const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { |
| 3542 | 3538 | anchorUserScrollGenerationRef.current += 1; |
| 3543 | 3539 | timelineScrollModeRef.current = "free-scrolling"; |
| ... | ... | @@ -3547,11 +3543,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3547 | 3543 | positionedTimelineAnchorRef.current = null; |
| 3548 | 3544 | settledTimelineAnchorRef.current = null; |
| 3549 | 3545 | activeTimelineAnchorIndexRef.current = null; |
| 3550 | pendingAnchorScrollRestoreRef.current = null; | |
| 3551 | if (anchorScrollRestoreFrameRef.current !== null) { | |
| 3552 | cancelAnimationFrame(anchorScrollRestoreFrameRef.current); | |
| 3553 | anchorScrollRestoreFrameRef.current = null; | |
| 3554 | } | |
| 3555 | 3546 | }, []); |
| 3556 | 3547 | const cancelTimelineLiveFollowForUserNavigationRef = useRef( |
| 3557 | 3548 | cancelTimelineLiveFollowForUserNavigation, |
| ... | ... | @@ -3607,7 +3598,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3607 | 3598 | }, |
| 3608 | 3599 | [composerOverlayHeight], |
| 3609 | 3600 | ); |
| 3610 | ||
| 3611 | 3601 | // Live-follow stays active after send/thread-open until an actual list scroll |
| 3612 | 3602 | // gesture opts out. |
| 3613 | 3603 | const scrollToEnd = useCallback((animated = false) => { |
| ... | ... | @@ -3619,7 +3609,12 @@ function ChatViewContent(props: ChatViewProps) { |
| 3619 | 3609 | activeTimelineAnchorIndexRef.current = null; |
| 3620 | 3610 | showScrollDebouncer.current.cancel(); |
| 3621 | 3611 | setShowScrollToBottom(false); |
| 3622 | void legendListRef.current?.scrollToEnd?.({ animated }); | |
| 3612 | setTimelineAnchor((current) => | |
| 3613 | current.messageId === null ? current : { ...current, messageId: null }, | |
| 3614 | ); | |
| 3615 | requestAnimationFrame(() => { | |
| 3616 | void legendListRef.current?.scrollToEnd?.({ animated }); | |
| 3617 | }); | |
| 3623 | 3618 | }, []); |
| 3624 | 3619 | useEffect(() => { |
| 3625 | 3620 | let removeListeners: (() => void) | null = null; |
| ... | ... | @@ -3758,75 +3753,23 @@ function ChatViewContent(props: ChatViewProps) { |
| 3758 | 3753 | } |
| 3759 | 3754 | return; |
| 3760 | 3755 | } |
| 3761 | const scrollNode = list.getScrollableNode(); | |
| 3762 | let finished = false; | |
| 3763 | const finishAnimatedPositioning = () => { | |
| 3764 | if (finished) { | |
| 3765 | return; | |
| 3766 | } | |
| 3767 | finished = true; | |
| 3768 | window.clearTimeout(fallbackTimer); | |
| 3769 | scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); | |
| 3770 | if (positionedTimelineAnchorRef.current !== messageId) { | |
| 3771 | return; | |
| 3772 | } | |
| 3773 | const scrollOffset = list.getState().scroll; | |
| 3774 | void list.scrollToOffset({ offset: scrollOffset, animated: false }); | |
| 3775 | settledTimelineAnchorRef.current = messageId; | |
| 3776 | }; | |
| 3777 | const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); | |
| 3778 | scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); | |
| 3779 | void list.scrollToIndex({ | |
| 3780 | index: anchorIndex, | |
| 3781 | animated: true, | |
| 3782 | viewPosition: 0, | |
| 3783 | viewOffset: CHAT_LIST_ANCHOR_OFFSET, | |
| 3784 | }); | |
| 3756 | void list | |
| 3757 | .scrollToIndex({ | |
| 3758 | index: anchorIndex, | |
| 3759 | animated: true, | |
| 3760 | viewPosition: 0, | |
| 3761 | viewOffset: CHAT_LIST_ANCHOR_OFFSET, | |
| 3762 | }) | |
| 3763 | .then(() => { | |
| 3764 | if (positionedTimelineAnchorRef.current !== messageId) { | |
| 3765 | return; | |
| 3766 | } | |
| 3767 | settledTimelineAnchorRef.current = messageId; | |
| 3768 | }); | |
| 3785 | 3769 | }); |
| 3786 | 3770 | }; |
| 3787 | 3771 | requestAnimationFrame(() => positionAnchor(12)); |
| 3788 | 3772 | }, []); |
| 3789 | const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { | |
| 3790 | if (settledTimelineAnchorRef.current !== messageId) { | |
| 3791 | return; | |
| 3792 | } | |
| 3793 | if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { | |
| 3794 | return; | |
| 3795 | } | |
| 3796 | const scrollOffset = legendListRef.current?.getState().scroll; | |
| 3797 | if (scrollOffset === undefined) { | |
| 3798 | return; | |
| 3799 | } | |
| 3800 | if (pendingAnchorScrollRestoreRef.current === null) { | |
| 3801 | pendingAnchorScrollRestoreRef.current = { | |
| 3802 | messageId, | |
| 3803 | offset: scrollOffset, | |
| 3804 | userScrollGeneration: anchorUserScrollGenerationRef.current, | |
| 3805 | }; | |
| 3806 | } | |
| 3807 | if (anchorScrollRestoreFrameRef.current !== null) { | |
| 3808 | return; | |
| 3809 | } | |
| 3810 | anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { | |
| 3811 | anchorScrollRestoreFrameRef.current = null; | |
| 3812 | const pending = pendingAnchorScrollRestoreRef.current; | |
| 3813 | pendingAnchorScrollRestoreRef.current = null; | |
| 3814 | if ( | |
| 3815 | pending && | |
| 3816 | settledTimelineAnchorRef.current === pending.messageId && | |
| 3817 | pending.userScrollGeneration === anchorUserScrollGenerationRef.current | |
| 3818 | ) { | |
| 3819 | const list = legendListRef.current; | |
| 3820 | const currentScrollOffset = list?.getState().scroll; | |
| 3821 | if ( | |
| 3822 | typeof currentScrollOffset === "number" && | |
| 3823 | Math.abs(currentScrollOffset - pending.offset) <= 2 | |
| 3824 | ) { | |
| 3825 | void list?.scrollToOffset({ offset: pending.offset, animated: false }); | |
| 3826 | } | |
| 3827 | } | |
| 3828 | }); | |
| 3829 | }, []); | |
| 3830 | 3773 | |
| 3831 | 3774 | const onIsAtEndChange = useCallback((isAtEnd: boolean) => { |
| 3832 | 3775 | if ( |
| ... | ... | @@ -3852,6 +3795,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3852 | 3795 | } |
| 3853 | 3796 | }, []); |
| 3854 | 3797 | |
| 3798 | // Anchored end space intentionally disables LegendList's normal end-follow so | |
| 3799 | // the sent message can stay near the top. T3 only owns streaming adjustments | |
| 3800 | // during that mode; LegendList owns ordinary end-follow everywhere else. | |
| 3855 | 3801 | useEffect(() => { |
| 3856 | 3802 | if (!activeThread?.id) { |
| 3857 | 3803 | return; |
| ... | ... | @@ -3859,6 +3805,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 3859 | 3805 | if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { |
| 3860 | 3806 | return; |
| 3861 | 3807 | } |
| 3808 | if (timelineScrollModeRef.current !== "anchoring-new-turn") { | |
| 3809 | return; | |
| 3810 | } | |
| 3862 | 3811 | |
| 3863 | 3812 | let secondFrame: number | null = null; |
| 3864 | 3813 | const frame = requestAnimationFrame(() => { |
| ... | ... | @@ -3880,28 +3829,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 3880 | 3829 | return; |
| 3881 | 3830 | } |
| 3882 | 3831 | |
| 3883 | if (timelineScrollModeRef.current === "anchoring-new-turn") { | |
| 3884 | const metrics = getActiveTimelineTurnMetrics(list); | |
| 3885 | if (!metrics) { | |
| 3886 | return; | |
| 3887 | } | |
| 3888 | if (metrics.scrollDeltaToRevealEnd <= 1) { | |
| 3889 | return; | |
| 3890 | } | |
| 3891 | ||
| 3892 | const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; | |
| 3893 | void list.scrollToOffset({ offset: nextOffset, animated: false }); | |
| 3894 | return; | |
| 3895 | } | |
| 3896 | ||
| 3897 | if (timelineScrollModeRef.current !== "following-end") { | |
| 3898 | return; | |
| 3899 | } | |
| 3900 | if (!timelineRealContentOverflowsViewport(list)) { | |
| 3832 | const metrics = getActiveTimelineTurnMetrics(list); | |
| 3833 | if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) { | |
| 3901 | 3834 | return; |
| 3902 | 3835 | } |
| 3903 | 3836 | |
| 3904 | void list.scrollToEnd?.({ animated: false }); | |
| 3837 | const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; | |
| 3838 | void list.scrollToOffset({ offset: nextOffset, animated: false }); | |
| 3905 | 3839 | }); |
| 3906 | 3840 | }); |
| 3907 | 3841 | |
| ... | ... | @@ -3911,12 +3845,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3911 | 3845 | cancelAnimationFrame(secondFrame); |
| 3912 | 3846 | } |
| 3913 | 3847 | }; |
| 3914 | }, [ | |
| 3915 | activeThread?.id, | |
| 3916 | timelineEntries, | |
| 3917 | getActiveTimelineTurnMetrics, | |
| 3918 | timelineRealContentOverflowsViewport, | |
| 3919 | ]); | |
| 3848 | }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); | |
| 3920 | 3849 | |
| 3921 | 3850 | useEffect(() => { |
| 3922 | 3851 | setPullRequestDialogState(null); |
| ... | ... | @@ -6118,7 +6047,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 6118 | 6047 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} |
| 6119 | 6048 | anchorMessageId={timelineAnchorMessageId} |
| 6120 | 6049 | onAnchorReady={onTimelineAnchorReady} |
| 6121 | onAnchorSizeChanged={onTimelineAnchorSizeChanged} | |
| 6122 | 6050 | contentInsetEndAdjustment={composerOverlayHeight} |
| 6123 | 6051 | liveFollowEnabled={timelineLiveFollowEnabled} |
| 6124 | 6052 | onIsAtEndChange={onIsAtEndChange} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+7-7| ... | ... | @@ -18,7 +18,6 @@ vi.mock("@legendapp/list/react", async () => { |
| 18 | 18 | anchorMaxSize?: number; |
| 19 | 19 | anchorOffset?: number; |
| 20 | 20 | onReady?: (info: { anchorIndex: number }) => void; |
| 21 | onSizeChanged?: (size: number) => void; | |
| 22 | 21 | }; |
| 23 | 22 | contentInsetEndAdjustment?: number; |
| 24 | 23 | className?: string; |
| ... | ... | @@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => { |
| 42 | 41 | ref?: Ref<LegendListRef>; |
| 43 | 42 | }) => { |
| 44 | 43 | if (props.anchoredEndSpace) { |
| 45 | props.anchoredEndSpace.onSizeChanged?.(240); | |
| 46 | 44 | props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); |
| 47 | 45 | } |
| 48 | 46 | return ( |
| ... | ... | @@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => { |
| 90 | 88 | ? props.maintainVisibleContentPosition.size |
| 91 | 89 | : undefined |
| 92 | 90 | } |
| 91 | data-maintain-visible-content-position-restore={ | |
| 92 | typeof props.maintainVisibleContentPosition === "object" | |
| 93 | ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) | |
| 94 | : undefined | |
| 95 | } | |
| 93 | 96 | > |
| 94 | 97 | {props.ListHeaderComponent} |
| 95 | 98 | {props.data.map((item) => ( |
| ... | ... | @@ -192,7 +195,6 @@ function buildProps() { |
| 192 | 195 | workspaceRoot: undefined, |
| 193 | 196 | anchorMessageId: null, |
| 194 | 197 | onAnchorReady: () => {}, |
| 195 | onAnchorSizeChanged: () => {}, | |
| 196 | 198 | contentInsetEndAdjustment: 0, |
| 197 | 199 | liveFollowEnabled: true, |
| 198 | 200 | onIsAtEndChange: () => {}, |
| ... | ... | @@ -386,7 +388,6 @@ describe("MessagesTimeline", () => { |
| 386 | 388 | |
| 387 | 389 | it("anchors a sent attachment message using its measured height", () => { |
| 388 | 390 | const onAnchorReady = vi.fn(); |
| 389 | const onAnchorSizeChanged = vi.fn(); | |
| 390 | 391 | const firstEntry = buildUserTimelineEntry("First prompt."); |
| 391 | 392 | const secondEntry = { |
| 392 | 393 | ...buildUserTimelineEntry("Newest prompt."), |
| ... | ... | @@ -411,7 +412,6 @@ describe("MessagesTimeline", () => { |
| 411 | 412 | {...buildProps()} |
| 412 | 413 | anchorMessageId={secondEntry.message.id} |
| 413 | 414 | onAnchorReady={onAnchorReady} |
| 414 | onAnchorSizeChanged={onAnchorSizeChanged} | |
| 415 | 415 | contentInsetEndAdjustment={144} |
| 416 | 416 | timelineEntries={[firstEntry, secondEntry]} |
| 417 | 417 | />, |
| ... | ... | @@ -426,10 +426,10 @@ describe("MessagesTimeline", () => { |
| 426 | 426 | expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); |
| 427 | 427 | expect(markup).toContain('data-maintain-visible-content-position="object"'); |
| 428 | 428 | expect(markup).toContain('data-maintain-visible-content-position-data="true"'); |
| 429 | expect(markup).toContain('data-maintain-visible-content-position-size="false"'); | |
| 429 | expect(markup).toContain('data-maintain-visible-content-position-size="true"'); | |
| 430 | expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); | |
| 430 | 431 | expect(onAnchorReady).toHaveBeenCalledOnce(); |
| 431 | 432 | expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); |
| 432 | expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); | |
| 433 | 433 | }); |
| 434 | 434 | |
| 435 | 435 | it("renders collapse controls for long user messages", () => { |
apps/web/src/components/chat/MessagesTimeline.tsx+84-69| ... | ... | @@ -29,7 +29,6 @@ import { |
| 29 | 29 | type MouseEvent, |
| 30 | 30 | type ReactNode, |
| 31 | 31 | } from "react"; |
| 32 | import { flushSync } from "react-dom"; | |
| 33 | 32 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 34 | 33 | import { FileDiff } from "@pierre/diffs/react"; |
| 35 | 34 | import { |
| ... | ... | @@ -142,7 +141,7 @@ interface TimelineRowSharedState { |
| 142 | 141 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 143 | 142 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 144 | 143 | onToggleTurnFold: (turnId: TurnId) => void; |
| 145 | onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; | |
| 144 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; | |
| 146 | 145 | agentPanelModel: AgentPanelModel; |
| 147 | 146 | onOpenAgents: () => void; |
| 148 | 147 | } |
| ... | ... | @@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({ |
| 189 | 188 | } |
| 190 | 189 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 191 | 190 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; |
| 191 | const TIMELINE_MAINTAIN_SCROLL_AT_END = { | |
| 192 | animated: false, | |
| 193 | on: { | |
| 194 | dataChange: true, | |
| 195 | itemLayout: true, | |
| 196 | layout: true, | |
| 197 | }, | |
| 198 | } as const; | |
| 192 | 199 | |
| 193 | 200 | // --------------------------------------------------------------------------- |
| 194 | 201 | // Props (public API) |
| ... | ... | @@ -220,7 +227,6 @@ interface MessagesTimelineProps { |
| 220 | 227 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; |
| 221 | 228 | anchorMessageId: MessageId | null; |
| 222 | 229 | onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; |
| 223 | onAnchorSizeChanged: (messageId: MessageId, size: number) => void; | |
| 224 | 230 | contentInsetEndAdjustment: number; |
| 225 | 231 | /** |
| 226 | 232 | * Whether the timeline should keep pinning to the live edge as content |
| ... | ... | @@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 267 | 273 | skills = EMPTY_TIMELINE_SKILLS, |
| 268 | 274 | anchorMessageId, |
| 269 | 275 | onAnchorReady, |
| 270 | onAnchorSizeChanged, | |
| 271 | 276 | contentInsetEndAdjustment, |
| 272 | 277 | liveFollowEnabled, |
| 273 | 278 | onIsAtEndChange, |
| ... | ... | @@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 278 | 283 | }: MessagesTimelineProps) { |
| 279 | 284 | const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set()); |
| 280 | 285 | const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set()); |
| 286 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | |
| 281 | 287 | const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); |
| 288 | const disclosureAnchorKeyRef = useRef<string | null>(null); | |
| 289 | const disclosureSettleFrameRef = useRef<number | null>(null); | |
| 290 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); | |
| 282 | 291 | |
| 283 | const onToggleTurnFold = useCallback((turnId: TurnId) => { | |
| 284 | setExpandedTurnIds((existing) => { | |
| 285 | const next = new Set(existing); | |
| 286 | if (next.has(turnId)) { | |
| 287 | next.delete(turnId); | |
| 288 | } else { | |
| 289 | next.add(turnId); | |
| 292 | useEffect(() => { | |
| 293 | return () => { | |
| 294 | if (disclosureSettleFrameRef.current !== null) { | |
| 295 | cancelAnimationFrame(disclosureSettleFrameRef.current); | |
| 290 | 296 | } |
| 291 | return next; | |
| 292 | }); | |
| 297 | if (disclosureSettleSecondFrameRef.current !== null) { | |
| 298 | cancelAnimationFrame(disclosureSettleSecondFrameRef.current); | |
| 299 | } | |
| 300 | }; | |
| 293 | 301 | }, []); |
| 294 | const onToggleWorkGroup = useCallback( | |
| 295 | (groupId: string, anchorElement?: HTMLElement) => { | |
| 296 | const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; | |
| 297 | 302 | |
| 298 | flushSync(() => { | |
| 299 | setExpandedWorkGroupIds((existing) => { | |
| 300 | const next = new Set(existing); | |
| 301 | if (next.has(groupId)) { | |
| 302 | next.delete(groupId); | |
| 303 | } else { | |
| 304 | next.add(groupId); | |
| 305 | } | |
| 306 | return next; | |
| 307 | }); | |
| 303 | const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { | |
| 304 | disclosureAnchorKeyRef.current = anchorKey; | |
| 305 | setDisclosureToggleSettling(true); | |
| 306 | if (disclosureSettleFrameRef.current !== null) { | |
| 307 | cancelAnimationFrame(disclosureSettleFrameRef.current); | |
| 308 | } | |
| 309 | if (disclosureSettleSecondFrameRef.current !== null) { | |
| 310 | cancelAnimationFrame(disclosureSettleSecondFrameRef.current); | |
| 311 | } | |
| 312 | disclosureSettleFrameRef.current = requestAnimationFrame(() => { | |
| 313 | disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { | |
| 314 | disclosureAnchorKeyRef.current = null; | |
| 315 | setDisclosureToggleSettling(false); | |
| 316 | disclosureSettleFrameRef.current = null; | |
| 317 | disclosureSettleSecondFrameRef.current = null; | |
| 308 | 318 | }); |
| 319 | }); | |
| 320 | }, []); | |
| 309 | 321 | |
| 310 | if (anchorBottomBeforeToggle === null || !anchorElement) { | |
| 311 | return; | |
| 312 | } | |
| 322 | const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { | |
| 323 | const disclosureAnchorKey = disclosureAnchorKeyRef.current; | |
| 324 | return disclosureAnchorKey === null || row.id === disclosureAnchorKey; | |
| 325 | }, []); | |
| 313 | 326 | |
| 314 | const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; | |
| 315 | if (Math.abs(delta) < 0.5) { | |
| 316 | return; | |
| 317 | } | |
| 327 | const maintainVisibleContentPosition = useMemo( | |
| 328 | () => ({ | |
| 329 | data: true, | |
| 330 | size: true, | |
| 331 | shouldRestorePosition: shouldRestoreVisibleContentPosition, | |
| 332 | }), | |
| 333 | [shouldRestoreVisibleContentPosition], | |
| 334 | ); | |
| 318 | 335 | |
| 319 | const list = listRef.current; | |
| 320 | const currentScroll = list?.getState?.().scroll; | |
| 321 | if (list && typeof currentScroll === "number") { | |
| 322 | list.scrollToOffset({ offset: currentScroll + delta, animated: false }); | |
| 323 | } | |
| 336 | const onToggleTurnFold = useCallback( | |
| 337 | (turnId: TurnId) => { | |
| 338 | suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); | |
| 339 | setExpandedTurnIds((existing) => { | |
| 340 | const next = new Set(existing); | |
| 341 | if (next.has(turnId)) { | |
| 342 | next.delete(turnId); | |
| 343 | } else { | |
| 344 | next.add(turnId); | |
| 345 | } | |
| 346 | return next; | |
| 347 | }); | |
| 348 | }, | |
| 349 | [suspendEndScrollMaintenanceForDisclosure], | |
| 350 | ); | |
| 351 | const onToggleWorkGroup = useCallback( | |
| 352 | (groupId: string, anchorKey: string) => { | |
| 353 | suspendEndScrollMaintenanceForDisclosure(anchorKey); | |
| 354 | setExpandedWorkGroupIds((existing) => { | |
| 355 | const next = new Set(existing); | |
| 356 | if (next.has(groupId)) { | |
| 357 | next.delete(groupId); | |
| 358 | } else { | |
| 359 | next.add(groupId); | |
| 360 | } | |
| 361 | return next; | |
| 362 | }); | |
| 324 | 363 | }, |
| 325 | [listRef], | |
| 364 | [suspendEndScrollMaintenanceForDisclosure], | |
| 326 | 365 | ); |
| 327 | 366 | |
| 328 | 367 | // An in-session interrupt leaves its turn expanded so the user keeps their |
| ... | ... | @@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 394 | 433 | }, |
| 395 | 434 | [anchorMessageId, onAnchorReady], |
| 396 | 435 | ); |
| 397 | const handleAnchorSizeChanged = useCallback( | |
| 398 | (size: number) => { | |
| 399 | if (anchorMessageId !== null) { | |
| 400 | onAnchorSizeChanged(anchorMessageId, size); | |
| 401 | } | |
| 402 | }, | |
| 403 | [anchorMessageId, onAnchorSizeChanged], | |
| 404 | ); | |
| 405 | 436 | const anchoredEndSpace = useMemo(() => { |
| 406 | 437 | const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => |
| 407 | 438 | row.kind === "message" ? row.message.id : null, |
| 408 | 439 | ); |
| 409 | return config | |
| 410 | ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } | |
| 411 | : undefined; | |
| 412 | }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); | |
| 440 | return config ? { ...config, onReady: handleAnchorReady } : undefined; | |
| 441 | }, [anchorMessageId, handleAnchorReady, rows]); | |
| 413 | 442 | |
| 414 | 443 | const handleScroll = useCallback(() => { |
| 415 | 444 | const state = listRef.current?.getState?.(); |
| ... | ... | @@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 554 | 583 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 555 | 584 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 556 | 585 | maintainScrollAtEnd={ |
| 557 | anchoredEndSpace || !liveFollowEnabled | |
| 586 | anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling | |
| 558 | 587 | ? false |
| 559 | : { | |
| 560 | animated: false, | |
| 561 | on: { | |
| 562 | dataChange: true, | |
| 563 | itemLayout: true, | |
| 564 | layout: true, | |
| 565 | }, | |
| 566 | } | |
| 588 | : TIMELINE_MAINTAIN_SCROLL_AT_END | |
| 567 | 589 | } |
| 568 | maintainVisibleContentPosition={{ | |
| 569 | data: true, | |
| 570 | size: false, | |
| 571 | }} | |
| 590 | maintainVisibleContentPosition={maintainVisibleContentPosition} | |
| 572 | 591 | onScroll={handleScroll} |
| 573 | 592 | className={cn( |
| 574 | 593 | "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", |
| ... | ... | @@ -1381,11 +1400,7 @@ function WorkGroupToggleTimelineRow({ |
| 1381 | 1400 | type="button" |
| 1382 | 1401 | className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" |
| 1383 | 1402 | aria-expanded={row.expanded} |
| 1384 | onClick={(event) => { | |
| 1385 | const anchorElement = | |
| 1386 | event.currentTarget.closest<HTMLElement>("[data-timeline-row-id]") ?? event.currentTarget; | |
| 1387 | ctx.onToggleWorkGroup(row.groupId, anchorElement); | |
| 1388 | }} | |
| 1403 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} | |
| 1389 | 1404 | > |
| 1390 | 1405 | <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted"> |
| 1391 | 1406 | <ChevronDownIcon |
pnpm-lock.yaml+9-21| ... | ... | @@ -536,8 +536,8 @@ importers: |
| 536 | 536 | specifier: ^0.9.0 |
| 537 | 537 | version: 0.9.0 |
| 538 | 538 | '@legendapp/list': |
| 539 | specifier: 3.2.0 | |
| 540 | version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 539 | specifier: 3.3.3 | |
| 540 | version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | |
| 541 | 541 | '@lexical/react': |
| 542 | 542 | specifier: ^0.41.0 |
| 543 | 543 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| ... | ... | @@ -2949,18 +2949,6 @@ packages: |
| 2949 | 2949 | resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} |
| 2950 | 2950 | engines: {node: '>=12'} |
| 2951 | 2951 | |
| 2952 | '@legendapp/list@3.2.0': | |
| 2953 | resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} | |
| 2954 | peerDependencies: | |
| 2955 | react: '*' | |
| 2956 | react-dom: '*' | |
| 2957 | react-native: '*' | |
| 2958 | peerDependenciesMeta: | |
| 2959 | react-dom: | |
| 2960 | optional: true | |
| 2961 | react-native: | |
| 2962 | optional: true | |
| 2963 | ||
| 2964 | 2952 | '@legendapp/list@3.3.3': |
| 2965 | 2953 | resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} |
| 2966 | 2954 | peerDependencies: |
| ... | ... | @@ -12998,13 +12986,6 @@ snapshots: |
| 12998 | 12986 | dependencies: |
| 12999 | 12987 | jsbi: 4.3.2 |
| 13000 | 12988 | |
| 13001 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 13002 | dependencies: | |
| 13003 | react: 19.2.6 | |
| 13004 | use-sync-external-store: 1.6.0(react@19.2.6) | |
| 13005 | optionalDependencies: | |
| 13006 | react-dom: 19.2.6(react@19.2.6) | |
| 13007 | ||
| 13008 | 12989 | '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 13009 | 12990 | dependencies: |
| 13010 | 12991 | react: 19.2.3 |
| ... | ... | @@ -13013,6 +12994,13 @@ snapshots: |
| 13013 | 12994 | react-dom: 19.2.3(react@19.2.3) |
| 13014 | 12995 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13015 | 12996 | |
| 12997 | '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | |
| 12998 | dependencies: | |
| 12999 | react: 19.2.6 | |
| 13000 | use-sync-external-store: 1.6.0(react@19.2.6) | |
| 13001 | optionalDependencies: | |
| 13002 | react-dom: 19.2.6(react@19.2.6) | |
| 13003 | ||
| 13016 | 13004 | '@lexical/clipboard@0.41.0': |
| 13017 | 13005 | dependencies: |
| 13018 | 13006 | '@lexical/html': 0.41.0 |