diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 08e4f8d26037..a87531673506 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -197,6 +197,34 @@ export function resolveTimelineMinimapIndexFromPointer(input: { return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1)))); } +export function resolveTimelineMinimapCurrentIndex(input: { + readonly scrollTop: number; + readonly scrollBottom: number; + readonly itemBounds: ReadonlyArray<{ + readonly top: number | null; + readonly height: number | null; + }>; +}): number | null { + let precedingIndex: number | null = null; + + for (const [index, item] of input.itemBounds.entries()) { + if (item.top === null) { + continue; + } + const inView = + item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop; + if (inView) { + // The first visible marker is the turn at the reader's current position. + return index; + } + if (item.top <= input.scrollTop) { + precedingIndex = index; + } + } + + return precedingIndex; +} + export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean { if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) { return false; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 67a3c78ed887..655156048c1d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -272,6 +272,25 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { } describe("MessagesTimeline", () => { + it("renders previous and next controls with the minimap", () => { + const first = buildUserTimelineEntry("First turn"); + const secondBase = buildUserTimelineEntry("Second turn"); + const second = { + ...secondBase, + id: "entry-2", + message: { + ...secondBase.message, + id: MessageId.make("message-2"), + }, + }; + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Previous turn"'); + expect(markup).toContain('aria-label="Next turn"'); + }); + // Expanding history uses this suite's existing test renderer, deprecated in // React 19. Migrate these interaction tests together when a DOM test setup is added. it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])( @@ -524,6 +543,7 @@ describe("MessagesTimeline", () => { const { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, @@ -583,6 +603,35 @@ describe("MessagesTimeline", () => { pointerY: 999, }), ).toBe(100); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 100, + scrollBottom: 500, + itemBounds: [ + { top: 80, height: 20 }, + { top: 120, height: 20 }, + { top: 220, height: 20 }, + ], + }), + ).toBe(1); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 150, + scrollBottom: 200, + itemBounds: [ + { top: 80, height: 20 }, + { top: 120, height: 20 }, + { top: 220, height: 20 }, + ], + }), + ).toBe(1); + expect( + resolveTimelineMinimapCurrentIndex({ + scrollTop: 0, + scrollBottom: 50, + itemBounds: [{ top: 80, height: 20 }], + }), + ).toBeNull(); expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false); expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 379617c7c0c2..1d4abe39bf39 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -85,6 +85,7 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, + ChevronUpIcon, CircleAlertIcon, DownloadIcon, EyeIcon, @@ -139,6 +140,7 @@ import { resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, + resolveTimelineMinimapCurrentIndex, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, @@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const scrollTop = state.scroll ?? 0; const scrollBottom = scrollTop + (state.scrollLength ?? 0); - for (const item of minimapItems) { - const strip = minimapStripMap.get(item.id); - if (!strip) { - continue; - } + const itemBounds = minimapItems.map((item) => ({ + top: resolveTimelineRowTop(state, item.rowIndex), + height: resolveTimelineRowHeight(state, item.rowIndex), + })); - const rowTop = resolveTimelineRowTop(state, item.rowIndex); - const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); + for (const [index, item] of minimapItems.entries()) { + const strip = minimapStripMap.get(item.id); + const bounds = itemBounds[index]; + const rowTop = bounds?.top ?? null; + const rowHeight = bounds?.height ?? null; const inView = rowTop !== null && rowTop < scrollBottom && rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; - strip.dataset.inView = inView ? "true" : "false"; + if (strip) { + strip.dataset.inView = inView ? "true" : "false"; + } } + const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({ + scrollTop, + scrollBottom, + itemBounds, + }); + setMinimapCurrentIndex((current) => + current === nextCurrentIndex ? current : nextCurrentIndex, + ); }, [ citationPositioning, listRef, @@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ items={minimapItems} hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} + currentIndex={minimapCurrentIndex} stripMap={minimapStripMap} onSelect={(item) => { onManualNavigation(); @@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { function TimelineMinimap({ hasPersistentGutter, hitStripWidth, + currentIndex, items, stripMap, onSelect, }: { hasPersistentGutter: boolean; hitStripWidth: number; + currentIndex: number | null; items: ReadonlyArray; stripMap: Map; onSelect: (item: TimelineMinimapItem) => void; @@ -999,6 +1017,11 @@ function TimelineMinimap({ : resolvedActiveIndex === items.length - 1 ? "-100%" : "-50%"; + const resolvedCurrentIndex = + currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null; + const previousItem = + resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null); + const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null); const resolveActiveIndexFromPointer = useCallback( (event: MouseEvent) => { @@ -1047,128 +1070,187 @@ function TimelineMinimap({ data-persistent-gutter={hasPersistentGutter ? "true" : "false"} >
- + ) : null} + + { + if (nextItem) onSelect(nextItem); + }} + /> +
); } +function TimelineMinimapNavigationButton({ + direction, + disabled, + onClick, +}: { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +}) { + const previous = direction === "previous"; + const label = previous ? "Previous turn" : "Next turn"; + const Icon = previous ? ChevronUpIcon : ChevronDownIcon; + + return ( + + + } + > + + + {label} + + ); +} + // --------------------------------------------------------------------------- // TimelineRowContent — the actual row component // ---------------------------------------------------------------------------