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"}
>
-
+
{
+ 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
// ---------------------------------------------------------------------------