authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-09-08 14:26:42+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 14:26:42+05:30
log134b7194b125dd4881ff6040f66997b23489b72e
tree5add9db30c3d23d28c8a42eed726bb6af4bd0695
parent7d620506a1fecf07aa2d623e72059d551709ae3a
signature Signed by PGP key B5690EEEBB952194

feat(web): add previous/next turn navigation in minimap (#8531)


3 files changed, 270 insertions(+), 111 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.ts+28
......@@ -197,6 +197,34 @@ export function resolveTimelineMinimapIndexFromPointer(input: {
197197 return Math.max(0, Math.min(input.itemCount - 1, Math.round(progress * (input.itemCount - 1))));
198198}
199199
200export function resolveTimelineMinimapCurrentIndex(input: {
201 readonly scrollTop: number;
202 readonly scrollBottom: number;
203 readonly itemBounds: ReadonlyArray<{
204 readonly top: number | null;
205 readonly height: number | null;
206 }>;
207}): number | null {
208 let precedingIndex: number | null = null;
209
210 for (const [index, item] of input.itemBounds.entries()) {
211 if (item.top === null) {
212 continue;
213 }
214 const inView =
215 item.top < input.scrollBottom && item.top + Math.max(1, item.height ?? 1) > input.scrollTop;
216 if (inView) {
217 // The first visible marker is the turn at the reader's current position.
218 return index;
219 }
220 if (item.top <= input.scrollTop) {
221 precedingIndex = index;
222 }
223 }
224
225 return precedingIndex;
226}
227
200228export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number): boolean {
201229 if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) {
202230 return false;
apps/web/src/components/chat/MessagesTimeline.test.tsx+49
......@@ -272,6 +272,25 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
272272}
273273
274274describe("MessagesTimeline", () => {
275 it("renders previous and next controls with the minimap", () => {
276 const first = buildUserTimelineEntry("First turn");
277 const secondBase = buildUserTimelineEntry("Second turn");
278 const second = {
279 ...secondBase,
280 id: "entry-2",
281 message: {
282 ...secondBase.message,
283 id: MessageId.make("message-2"),
284 },
285 };
286 const markup = renderToStaticMarkup(
287 <MessagesTimeline {...buildProps()} timelineEntries={[first, second]} />,
288 );
289
290 expect(markup).toContain('aria-label="Previous turn"');
291 expect(markup).toContain('aria-label="Next turn"');
292 });
293
275294 // Expanding history uses this suite's existing test renderer, deprecated in
276295 // React 19. Migrate these interaction tests together when a DOM test setup is added.
277296 it.each([{}, { text: "Text-only answer", file: "Answer with a file" }])(
......@@ -524,6 +543,7 @@ describe("MessagesTimeline", () => {
524543 const {
525544 resolveTimelineIsAtEnd,
526545 resolveTimelineMinimapHasPersistentGutter,
546 resolveTimelineMinimapCurrentIndex,
527547 resolveTimelineMinimapHeightStyle,
528548 resolveTimelineMinimapHitStripWidth,
529549 resolveTimelineMinimapIndexFromPointer,
......@@ -583,6 +603,35 @@ describe("MessagesTimeline", () => {
583603 pointerY: 999,
584604 }),
585605 ).toBe(100);
606 expect(
607 resolveTimelineMinimapCurrentIndex({
608 scrollTop: 100,
609 scrollBottom: 500,
610 itemBounds: [
611 { top: 80, height: 20 },
612 { top: 120, height: 20 },
613 { top: 220, height: 20 },
614 ],
615 }),
616 ).toBe(1);
617 expect(
618 resolveTimelineMinimapCurrentIndex({
619 scrollTop: 150,
620 scrollBottom: 200,
621 itemBounds: [
622 { top: 80, height: 20 },
623 { top: 120, height: 20 },
624 { top: 220, height: 20 },
625 ],
626 }),
627 ).toBe(1);
628 expect(
629 resolveTimelineMinimapCurrentIndex({
630 scrollTop: 0,
631 scrollBottom: 50,
632 itemBounds: [{ top: 80, height: 20 }],
633 }),
634 ).toBeNull();
586635 expect(resolveTimelineMinimapHasPersistentGutter(832)).toBe(false);
587636 expect(resolveTimelineMinimapHasPersistentGutter(863)).toBe(false);
588637 expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true);
apps/web/src/components/chat/MessagesTimeline.tsx+193-111
......@@ -85,6 +85,7 @@ import {
8585 CheckIcon,
8686 ChevronDownIcon,
8787 ChevronRightIcon,
88 ChevronUpIcon,
8889 CircleAlertIcon,
8990 DownloadIcon,
9091 EyeIcon,
......@@ -139,6 +140,7 @@ import {
139140 resolveAssistantMessageCopyState,
140141 resolveTimelineIsAtEnd,
141142 resolveTimelineMinimapHasPersistentGutter,
143 resolveTimelineMinimapCurrentIndex,
142144 resolveTimelineMinimapHeightStyle,
143145 resolveTimelineMinimapHitStripWidth,
144146 resolveTimelineMinimapIndexFromPointer,
......@@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
593595 });
594596 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
595597 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
598 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
596599 const handleAnchorReady = useCallback(
597600 (info: { anchorIndex: number | undefined }) => {
598601 if (anchorMessageId !== null && info.anchorIndex !== undefined) {
......@@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({
665668 const scrollTop = state.scroll ?? 0;
666669 const scrollBottom = scrollTop + (state.scrollLength ?? 0);
667670
668 for (const item of minimapItems) {
669 const strip = minimapStripMap.get(item.id);
670 if (!strip) {
671 continue;
672 }
671 const itemBounds = minimapItems.map((item) => ({
672 top: resolveTimelineRowTop(state, item.rowIndex),
673 height: resolveTimelineRowHeight(state, item.rowIndex),
674 }));
673675
674 const rowTop = resolveTimelineRowTop(state, item.rowIndex);
675 const rowHeight = resolveTimelineRowHeight(state, item.rowIndex);
676 for (const [index, item] of minimapItems.entries()) {
677 const strip = minimapStripMap.get(item.id);
678 const bounds = itemBounds[index];
679 const rowTop = bounds?.top ?? null;
680 const rowHeight = bounds?.height ?? null;
676681 const inView =
677682 rowTop !== null &&
678683 rowTop < scrollBottom &&
679684 rowTop + Math.max(1, rowHeight ?? 1) > scrollTop;
680685
681 strip.dataset.inView = inView ? "true" : "false";
686 if (strip) {
687 strip.dataset.inView = inView ? "true" : "false";
688 }
682689 }
690 const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({
691 scrollTop,
692 scrollBottom,
693 itemBounds,
694 });
695 setMinimapCurrentIndex((current) =>
696 current === nextCurrentIndex ? current : nextCurrentIndex,
697 );
683698 }, [
684699 citationPositioning,
685700 listRef,
......@@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
871886 items={minimapItems}
872887 hasPersistentGutter={minimapHasPersistentGutter}
873888 hitStripWidth={minimapHitStripWidth}
889 currentIndex={minimapCurrentIndex}
874890 stripMap={minimapStripMap}
875891 onSelect={(item) => {
876892 onManualNavigation();
......@@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean {
972988function TimelineMinimap({
973989 hasPersistentGutter,
974990 hitStripWidth,
991 currentIndex,
975992 items,
976993 stripMap,
977994 onSelect,
978995}: {
979996 hasPersistentGutter: boolean;
980997 hitStripWidth: number;
998 currentIndex: number | null;
981999 items: ReadonlyArray<TimelineMinimapItem>;
9821000 stripMap: Map<string, HTMLSpanElement>;
9831001 onSelect: (item: TimelineMinimapItem) => void;
......@@ -999,6 +1017,11 @@ function TimelineMinimap({
9991017 : resolvedActiveIndex === items.length - 1
10001018 ? "-100%"
10011019 : "-50%";
1020 const resolvedCurrentIndex =
1021 currentIndex !== null && currentIndex >= 0 && currentIndex < items.length ? currentIndex : null;
1022 const previousItem =
1023 resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex - 1] ?? null);
1024 const nextItem = resolvedCurrentIndex === null ? null : (items[resolvedCurrentIndex + 1] ?? null);
10021025
10031026 const resolveActiveIndexFromPointer = useCallback(
10041027 (event: MouseEvent<HTMLElement>) => {
......@@ -1047,128 +1070,187 @@ function TimelineMinimap({
10471070 data-persistent-gutter={hasPersistentGutter ? "true" : "false"}
10481071 >
10491072 <div className="relative h-full w-full select-none">
1050 <button
1051 aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
1073 <div
10521074 className={cn(
1053 "absolute top-1/2 left-3 -translate-y-1/2 cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70",
1075 "absolute top-1/2 left-3 -translate-y-1/2",
10541076 // The strip is width-capped to the side gutter so it never overlays
10551077 // the centered content column; with no usable gutter it goes inert.
10561078 hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none",
10571079 )}
1058 onBlur={() => setActiveIndex(null)}
1059 onClick={(event) => {
1060 if (timelineMinimapEventTargetsPreview(event.target)) {
1061 return;
1062 }
1063 const nextIndex = resolveActiveIndexFromPointer(event);
1064 const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null);
1065 if (nextItem) {
1066 onSelect(nextItem);
1067 }
1068 event.currentTarget.blur();
1069 }}
1070 onFocus={() => setActiveIndex((current) => current ?? 0)}
1071 onKeyDown={(event) => {
1072 if (event.key === "ArrowDown") {
1073 event.preventDefault();
1074 moveActiveIndex(1);
1075 } else if (event.key === "ArrowUp") {
1076 event.preventDefault();
1077 moveActiveIndex(-1);
1078 } else if (event.key === "Home") {
1079 event.preventDefault();
1080 setActiveIndex(0);
1081 } else if (event.key === "End") {
1082 event.preventDefault();
1083 setActiveIndex(items.length - 1);
1084 } else if (event.key === "Enter" || event.key === " ") {
1085 event.preventDefault();
1086 if (activeItem) {
1087 onSelect(activeItem);
1088 }
1089 }
1090 }}
1091 onMouseLeave={() => setActiveIndex(null)}
1092 onMouseMove={updateActiveIndexFromPointer}
1093 onMouseDown={(event) => {
1094 if (timelineMinimapEventTargetsPreview(event.target)) {
1095 return;
1096 }
1097 event.preventDefault();
1098 }}
10991080 style={{
11001081 height: resolveTimelineMinimapHeightStyle(items.length),
11011082 width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null),
11021083 }}
1103 type="button"
11041084 >
1105 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
1106 {items.map((item, index) => {
1107 const top = `${resolveTimelineMinimapTopPercent(index, items.length)}%`;
1108 const activeDistance =
1109 resolvedActiveIndex === null ? null : Math.abs(index - resolvedActiveIndex);
1110 return (
1085 <TimelineMinimapNavigationButton
1086 direction="previous"
1087 disabled={previousItem === null}
1088 onClick={() => {
1089 if (previousItem) onSelect(previousItem);
1090 }}
1091 />
1092 <button
1093 aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`}
1094 className="absolute inset-y-0 left-0 w-full cursor-pointer bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
1095 onBlur={() => setActiveIndex(null)}
1096 onClick={(event) => {
1097 if (timelineMinimapEventTargetsPreview(event.target)) {
1098 return;
1099 }
1100 const nextIndex = resolveActiveIndexFromPointer(event);
1101 const selectedItem = nextIndex === null ? null : (items[nextIndex] ?? null);
1102 if (selectedItem) {
1103 onSelect(selectedItem);
1104 }
1105 event.currentTarget.blur();
1106 }}
1107 onFocus={() => setActiveIndex((current) => current ?? resolvedCurrentIndex ?? 0)}
1108 onKeyDown={(event) => {
1109 if (event.key === "ArrowDown") {
1110 event.preventDefault();
1111 moveActiveIndex(1);
1112 } else if (event.key === "ArrowUp") {
1113 event.preventDefault();
1114 moveActiveIndex(-1);
1115 } else if (event.key === "Home") {
1116 event.preventDefault();
1117 setActiveIndex(0);
1118 } else if (event.key === "End") {
1119 event.preventDefault();
1120 setActiveIndex(items.length - 1);
1121 } else if (event.key === "Enter" || event.key === " ") {
1122 event.preventDefault();
1123 if (activeItem) {
1124 onSelect(activeItem);
1125 }
1126 }
1127 }}
1128 onMouseLeave={() => setActiveIndex(null)}
1129 onMouseMove={updateActiveIndexFromPointer}
1130 onMouseDown={(event) => {
1131 if (timelineMinimapEventTargetsPreview(event.target)) {
1132 return;
1133 }
1134 event.preventDefault();
1135 }}
1136 type="button"
1137 >
1138 <div className="absolute top-0 left-3 h-full w-px bg-border/15" />
1139 {items.map((item, index) => {
1140 const top = `${resolveTimelineMinimapTopPercent(index, items.length)}%`;
1141 const activeDistance =
1142 resolvedActiveIndex === null ? null : Math.abs(index - resolvedActiveIndex);
1143 return (
1144 <span
1145 aria-hidden="true"
1146 className={cn(
1147 "pointer-events-none absolute left-0 h-0.5 -translate-y-1/2 rounded-full bg-muted-foreground/35 transition-[background-color,width] duration-150 data-[in-view=true]:bg-foreground/90",
1148 activeDistance === 0
1149 ? "w-6 bg-muted-foreground/75"
1150 : activeDistance === 1
1151 ? "w-4"
1152 : activeDistance === 2
1153 ? "w-2.5"
1154 : "w-2",
1155 )}
1156 data-in-view="false"
1157 data-minimap-strip
1158 key={item.id}
1159 ref={(node) => {
1160 if (node) {
1161 stripMap.set(item.id, node);
1162 } else {
1163 stripMap.delete(item.id);
1164 }
1165 }}
1166 style={{ top }}
1167 />
1168 );
1169 })}
1170 {activeItem ? (
11111171 <span
1112 aria-hidden="true"
1113 className={cn(
1114 "pointer-events-none absolute left-0 h-0.5 -translate-y-1/2 rounded-full bg-muted-foreground/35 transition-[background-color,width] duration-150 data-[in-view=true]:bg-foreground/90",
1115 activeDistance === 0
1116 ? "w-6 bg-muted-foreground/75"
1117 : activeDistance === 1
1118 ? "w-4"
1119 : activeDistance === 2
1120 ? "w-2.5"
1121 : "w-2",
1122 )}
1123 data-in-view="false"
1124 data-minimap-strip
1125 key={item.id}
1126 ref={(node) => {
1127 if (node) {
1128 stripMap.set(item.id, node);
1129 } else {
1130 stripMap.delete(item.id);
1131 }
1172 className="pointer-events-auto absolute left-8 w-80 cursor-text select-text"
1173 data-minimap-preview
1174 onMouseMove={(event) => event.stopPropagation()}
1175 style={{
1176 top: `${activeTopPercent}%`,
1177 transform: `translateY(${activeTooltipTranslate})`,
11321178 }}
1133 style={{ top }}
1134 />
1135 );
1136 })}
1137 {activeItem ? (
1138 <span
1139 className="pointer-events-auto absolute left-8 w-80 cursor-text select-text"
1140 data-minimap-preview
1141 onMouseMove={(event) => event.stopPropagation()}
1142 style={{
1143 top: `${activeTopPercent}%`,
1144 transform: `translateY(${activeTooltipTranslate})`,
1145 }}
1146 >
1147 <span className="dropdown-glass block rounded-xl p-3 text-left text-popover-foreground shadow-xl shadow-black/25">
1148 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
1149 {activeItem.userText ?? "User message"}
1150 </span>
1151 {activeItem.assistantText ? (
1152 <span
1153 className="mt-1 max-h-[3.75rem] overflow-hidden text-muted-foreground text-sm leading-5"
1154 style={{
1155 display: "-webkit-box",
1156 WebkitBoxOrient: "vertical",
1157 WebkitLineClamp: 3,
1158 }}
1159 >
1160 {activeItem.assistantText}
1179 >
1180 <span className="dropdown-glass block rounded-xl p-3 text-left text-popover-foreground shadow-xl shadow-black/25">
1181 <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5">
1182 {activeItem.userText ?? "User message"}
11611183 </span>
1162 ) : null}
1184 {activeItem.assistantText ? (
1185 <span
1186 className="mt-1 max-h-[3.75rem] overflow-hidden text-muted-foreground text-sm leading-5"
1187 style={{
1188 display: "-webkit-box",
1189 WebkitBoxOrient: "vertical",
1190 WebkitLineClamp: 3,
1191 }}
1192 >
1193 {activeItem.assistantText}
1194 </span>
1195 ) : null}
1196 </span>
11631197 </span>
1164 </span>
1165 ) : null}
1166 </button>
1198 ) : null}
1199 </button>
1200 <TimelineMinimapNavigationButton
1201 direction="next"
1202 disabled={nextItem === null}
1203 onClick={() => {
1204 if (nextItem) onSelect(nextItem);
1205 }}
1206 />
1207 </div>
11671208 </div>
11681209 </div>
11691210 );
11701211}
11711212
1213function TimelineMinimapNavigationButton({
1214 direction,
1215 disabled,
1216 onClick,
1217}: {
1218 direction: "previous" | "next";
1219 disabled: boolean;
1220 onClick: () => void;
1221}) {
1222 const previous = direction === "previous";
1223 const label = previous ? "Previous turn" : "Next turn";
1224 const Icon = previous ? ChevronUpIcon : ChevronDownIcon;
1225
1226 return (
1227 <Tooltip>
1228 <TooltipTrigger
1229 render={
1230 <span
1231 className={cn(
1232 "absolute left-1 z-10 inline-flex -translate-x-1/2 opacity-0 pointer-events-auto transition-opacity duration-150 hover:opacity-100 focus-within:opacity-100",
1233 previous ? "bottom-[calc(100%+2px)]" : "top-[calc(100%+2px)]",
1234 )}
1235 />
1236 }
1237 >
1238 <Button
1239 aria-label={label}
1240 disabled={disabled}
1241 onClick={onClick}
1242 size="icon-micro"
1243 type="button"
1244 variant="ghost-muted"
1245 >
1246 <Icon className="size-4 text-foreground/90" />
1247 </Button>
1248 </TooltipTrigger>
1249 <TooltipPopup side={previous ? "top" : "bottom"}>{label}</TooltipPopup>
1250 </Tooltip>
1251 );
1252}
1253
11721254// ---------------------------------------------------------------------------
11731255// TimelineRowContent — the actual row component
11741256// ---------------------------------------------------------------------------