| ... | ... | @@ -85,6 +85,7 @@ import { |
| 85 | 85 | CheckIcon, |
| 86 | 86 | ChevronDownIcon, |
| 87 | 87 | ChevronRightIcon, |
| 88 | ChevronUpIcon, |
| 88 | 89 | CircleAlertIcon, |
| 89 | 90 | DownloadIcon, |
| 90 | 91 | EyeIcon, |
| ... | ... | @@ -139,6 +140,7 @@ import { |
| 139 | 140 | resolveAssistantMessageCopyState, |
| 140 | 141 | resolveTimelineIsAtEnd, |
| 141 | 142 | resolveTimelineMinimapHasPersistentGutter, |
| 143 | resolveTimelineMinimapCurrentIndex, |
| 142 | 144 | resolveTimelineMinimapHeightStyle, |
| 143 | 145 | resolveTimelineMinimapHitStripWidth, |
| 144 | 146 | resolveTimelineMinimapIndexFromPointer, |
| ... | ... | @@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 593 | 595 | }); |
| 594 | 596 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 595 | 597 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 598 | const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null); |
| 596 | 599 | const handleAnchorReady = useCallback( |
| 597 | 600 | (info: { anchorIndex: number | undefined }) => { |
| 598 | 601 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { |
| ... | ... | @@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 665 | 668 | const scrollTop = state.scroll ?? 0; |
| 666 | 669 | const scrollBottom = scrollTop + (state.scrollLength ?? 0); |
| 667 | 670 | |
| 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 | })); |
| 673 | 675 | |
| 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; |
| 676 | 681 | const inView = |
| 677 | 682 | rowTop !== null && |
| 678 | 683 | rowTop < scrollBottom && |
| 679 | 684 | rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; |
| 680 | 685 | |
| 681 | | strip.dataset.inView = inView ? "true" : "false"; |
| 686 | if (strip) { |
| 687 | strip.dataset.inView = inView ? "true" : "false"; |
| 688 | } |
| 682 | 689 | } |
| 690 | const nextCurrentIndex = resolveTimelineMinimapCurrentIndex({ |
| 691 | scrollTop, |
| 692 | scrollBottom, |
| 693 | itemBounds, |
| 694 | }); |
| 695 | setMinimapCurrentIndex((current) => |
| 696 | current === nextCurrentIndex ? current : nextCurrentIndex, |
| 697 | ); |
| 683 | 698 | }, [ |
| 684 | 699 | citationPositioning, |
| 685 | 700 | listRef, |
| ... | ... | @@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 871 | 886 | items={minimapItems} |
| 872 | 887 | hasPersistentGutter={minimapHasPersistentGutter} |
| 873 | 888 | hitStripWidth={minimapHitStripWidth} |
| 889 | currentIndex={minimapCurrentIndex} |
| 874 | 890 | stripMap={minimapStripMap} |
| 875 | 891 | onSelect={(item) => { |
| 876 | 892 | onManualNavigation(); |
| ... | ... | @@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { |
| 972 | 988 | function TimelineMinimap({ |
| 973 | 989 | hasPersistentGutter, |
| 974 | 990 | hitStripWidth, |
| 991 | currentIndex, |
| 975 | 992 | items, |
| 976 | 993 | stripMap, |
| 977 | 994 | onSelect, |
| 978 | 995 | }: { |
| 979 | 996 | hasPersistentGutter: boolean; |
| 980 | 997 | hitStripWidth: number; |
| 998 | currentIndex: number | null; |
| 981 | 999 | items: ReadonlyArray<TimelineMinimapItem>; |
| 982 | 1000 | stripMap: Map<string, HTMLSpanElement>; |
| 983 | 1001 | onSelect: (item: TimelineMinimapItem) => void; |
| ... | ... | @@ -999,6 +1017,11 @@ function TimelineMinimap({ |
| 999 | 1017 | : resolvedActiveIndex === items.length - 1 |
| 1000 | 1018 | ? "-100%" |
| 1001 | 1019 | : "-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); |
| 1002 | 1025 | |
| 1003 | 1026 | const resolveActiveIndexFromPointer = useCallback( |
| 1004 | 1027 | (event: MouseEvent<HTMLElement>) => { |
| ... | ... | @@ -1047,128 +1070,187 @@ function TimelineMinimap({ |
| 1047 | 1070 | data-persistent-gutter={hasPersistentGutter ? "true" : "false"} |
| 1048 | 1071 | > |
| 1049 | 1072 | <div className="relative h-full w-full select-none"> |
| 1050 | | <button |
| 1051 | | aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`} |
| 1073 | <div |
| 1052 | 1074 | 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", |
| 1054 | 1076 | // The strip is width-capped to the side gutter so it never overlays |
| 1055 | 1077 | // the centered content column; with no usable gutter it goes inert. |
| 1056 | 1078 | hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none", |
| 1057 | 1079 | )} |
| 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 | | }} |
| 1099 | 1080 | style={{ |
| 1100 | 1081 | height: resolveTimelineMinimapHeightStyle(items.length), |
| 1101 | 1082 | width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null), |
| 1102 | 1083 | }} |
| 1103 | | type="button" |
| 1104 | 1084 | > |
| 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 ? ( |
| 1111 | 1171 | <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})`, |
| 1132 | 1178 | }} |
| 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"} |
| 1161 | 1183 | </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> |
| 1163 | 1197 | </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> |
| 1167 | 1208 | </div> |
| 1168 | 1209 | </div> |
| 1169 | 1210 | ); |
| 1170 | 1211 | } |
| 1171 | 1212 | |
| 1213 | function 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 | |
| 1172 | 1254 | // --------------------------------------------------------------------------- |
| 1173 | 1255 | // TimelineRowContent — the actual row component |
| 1174 | 1256 | // --------------------------------------------------------------------------- |