| ... | @@ -85,6 +85,7 @@ import { | ... | @@ -85,6 +85,7 @@ import { |
| 85 | CheckIcon, | 85 | CheckIcon, |
| 86 | ChevronDownIcon, | 86 | ChevronDownIcon, |
| 87 | ChevronRightIcon, | 87 | ChevronRightIcon, |
| | 88 | ChevronUpIcon, |
| 88 | CircleAlertIcon, | 89 | CircleAlertIcon, |
| 89 | DownloadIcon, | 90 | DownloadIcon, |
| 90 | EyeIcon, | 91 | EyeIcon, |
| ... | @@ -139,6 +140,7 @@ import { | ... | @@ -139,6 +140,7 @@ import { |
| 139 | resolveAssistantMessageCopyState, | 140 | resolveAssistantMessageCopyState, |
| 140 | resolveTimelineIsAtEnd, | 141 | resolveTimelineIsAtEnd, |
| 141 | resolveTimelineMinimapHasPersistentGutter, | 142 | resolveTimelineMinimapHasPersistentGutter, |
| | 143 | resolveTimelineMinimapCurrentIndex, |
| 142 | resolveTimelineMinimapHeightStyle, | 144 | resolveTimelineMinimapHeightStyle, |
| 143 | resolveTimelineMinimapHitStripWidth, | 145 | resolveTimelineMinimapHitStripWidth, |
| 144 | resolveTimelineMinimapIndexFromPointer, | 146 | resolveTimelineMinimapIndexFromPointer, |
| ... | @@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -593,6 +595,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 593 | }); | 595 | }); |
| 594 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); | 596 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 595 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); | 597 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| | 598 | const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null); |
| 596 | const handleAnchorReady = useCallback( | 599 | const handleAnchorReady = useCallback( |
| 597 | (info: { anchorIndex: number | undefined }) => { | 600 | (info: { anchorIndex: number | undefined }) => { |
| 598 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { | 601 | if (anchorMessageId !== null && info.anchorIndex !== undefined) { |
| ... | @@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -665,21 +668,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 665 | const scrollTop = state.scroll ?? 0; | 668 | const scrollTop = state.scroll ?? 0; |
| 666 | const scrollBottom = scrollTop + (state.scrollLength ?? 0); | 669 | const scrollBottom = scrollTop + (state.scrollLength ?? 0); |
| 667 | | 670 | |
| 668 | for (const item of minimapItems) { | 671 | const itemBounds = minimapItems.map((item) => ({ |
| 669 | const strip = minimapStripMap.get(item.id); | 672 | top: resolveTimelineRowTop(state, item.rowIndex), |
| 670 | if (!strip) { | 673 | height: resolveTimelineRowHeight(state, item.rowIndex), |
| 671 | continue; | 674 | })); |
| 672 | } | | |
| 673 | | 675 | |
| 674 | const rowTop = resolveTimelineRowTop(state, item.rowIndex); | 676 | for (const [index, item] of minimapItems.entries()) { |
| 675 | const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); | 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 | const inView = | 681 | const inView = |
| 677 | rowTop !== null && | 682 | rowTop !== null && |
| 678 | rowTop < scrollBottom && | 683 | rowTop < scrollBottom && |
| 679 | rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; | 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 | citationPositioning, | 699 | citationPositioning, |
| 685 | listRef, | 700 | listRef, |
| ... | @@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -871,6 +886,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 871 | items={minimapItems} | 886 | items={minimapItems} |
| 872 | hasPersistentGutter={minimapHasPersistentGutter} | 887 | hasPersistentGutter={minimapHasPersistentGutter} |
| 873 | hitStripWidth={minimapHitStripWidth} | 888 | hitStripWidth={minimapHitStripWidth} |
| | 889 | currentIndex={minimapCurrentIndex} |
| 874 | stripMap={minimapStripMap} | 890 | stripMap={minimapStripMap} |
| 875 | onSelect={(item) => { | 891 | onSelect={(item) => { |
| 876 | onManualNavigation(); | 892 | onManualNavigation(); |
| ... | @@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { | ... | @@ -972,12 +988,14 @@ function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { |
| 972 | function TimelineMinimap({ | 988 | function TimelineMinimap({ |
| 973 | hasPersistentGutter, | 989 | hasPersistentGutter, |
| 974 | hitStripWidth, | 990 | hitStripWidth, |
| | 991 | currentIndex, |
| 975 | items, | 992 | items, |
| 976 | stripMap, | 993 | stripMap, |
| 977 | onSelect, | 994 | onSelect, |
| 978 | }: { | 995 | }: { |
| 979 | hasPersistentGutter: boolean; | 996 | hasPersistentGutter: boolean; |
| 980 | hitStripWidth: number; | 997 | hitStripWidth: number; |
| | 998 | currentIndex: number | null; |
| 981 | items: ReadonlyArray<TimelineMinimapItem>; | 999 | items: ReadonlyArray<TimelineMinimapItem>; |
| 982 | stripMap: Map<string, HTMLSpanElement>; | 1000 | stripMap: Map<string, HTMLSpanElement>; |
| 983 | onSelect: (item: TimelineMinimapItem) => void; | 1001 | onSelect: (item: TimelineMinimapItem) => void; |
| ... | @@ -999,6 +1017,11 @@ function TimelineMinimap({ | ... | @@ -999,6 +1017,11 @@ function TimelineMinimap({ |
| 999 | : resolvedActiveIndex === items.length - 1 | 1017 | : resolvedActiveIndex === items.length - 1 |
| 1000 | ? "-100%" | 1018 | ? "-100%" |
| 1001 | : "-50%"; | 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 | const resolveActiveIndexFromPointer = useCallback( | 1026 | const resolveActiveIndexFromPointer = useCallback( |
| 1004 | (event: MouseEvent<HTMLElement>) => { | 1027 | (event: MouseEvent<HTMLElement>) => { |
| ... | @@ -1047,128 +1070,187 @@ function TimelineMinimap({ | ... | @@ -1047,128 +1070,187 @@ function TimelineMinimap({ |
| 1047 | data-persistent-gutter={hasPersistentGutter ? "true" : "false"} | 1070 | data-persistent-gutter={hasPersistentGutter ? "true" : "false"} |
| 1048 | > | 1071 | > |
| 1049 | <div className="relative h-full w-full select-none"> | 1072 | <div className="relative h-full w-full select-none"> |
| 1050 | <button | 1073 | <div |
| 1051 | aria-label={`Jump to message: ${activeItem?.userText ?? "User message"}`} | | |
| 1052 | className={cn( | 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 | // The strip is width-capped to the side gutter so it never overlays | 1076 | // The strip is width-capped to the side gutter so it never overlays |
| 1055 | // the centered content column; with no usable gutter it goes inert. | 1077 | // the centered content column; with no usable gutter it goes inert. |
| 1056 | hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none", | 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 | style={{ | 1080 | style={{ |
| 1100 | height: resolveTimelineMinimapHeightStyle(items.length), | 1081 | height: resolveTimelineMinimapHeightStyle(items.length), |
| 1101 | width: resolveTimelineMinimapInteractiveWidth(hitStripWidth, activeItem !== null), | 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" /> | 1085 | <TimelineMinimapNavigationButton |
| 1106 | {items.map((item, index) => { | 1086 | direction="previous" |
| 1107 | const top = `${resolveTimelineMinimapTopPercent(index, items.length)}%`; | 1087 | disabled={previousItem === null} |
| 1108 | const activeDistance = | 1088 | onClick={() => { |
| 1109 | resolvedActiveIndex === null ? null : Math.abs(index - resolvedActiveIndex); | 1089 | if (previousItem) onSelect(previousItem); |
| 1110 | return ( | 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 | <span | 1171 | <span |
| 1112 | aria-hidden="true" | 1172 | className="pointer-events-auto absolute left-8 w-80 cursor-text select-text" |
| 1113 | className={cn( | 1173 | data-minimap-preview |
| 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", | 1174 | onMouseMove={(event) => event.stopPropagation()} |
| 1115 | activeDistance === 0 | 1175 | style={{ |
| 1116 | ? "w-6 bg-muted-foreground/75" | 1176 | top: `${activeTopPercent}%`, |
| 1117 | : activeDistance === 1 | 1177 | transform: `translateY(${activeTooltipTranslate})`, |
| 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 | } | | |
| 1132 | }} | 1178 | }} |
| 1133 | style={{ top }} | 1179 | > |
| 1134 | /> | 1180 | <span className="dropdown-glass block rounded-xl p-3 text-left text-popover-foreground shadow-xl shadow-black/25"> |
| 1135 | ); | 1181 | <span className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium leading-5"> |
| 1136 | })} | 1182 | {activeItem.userText ?? "User message"} |
| 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} | | |
| 1161 | </span> | 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 | </span> | 1197 | </span> |
| 1164 | </span> | 1198 | ) : null} |
| 1165 | ) : null} | 1199 | </button> |
| 1166 | </button> | 1200 | <TimelineMinimapNavigationButton |
| | 1201 | direction="next" |
| | 1202 | disabled={nextItem === null} |
| | 1203 | onClick={() => { |
| | 1204 | if (nextItem) onSelect(nextItem); |
| | 1205 | }} |
| | 1206 | /> |
| | 1207 | </div> |
| 1167 | </div> | 1208 | </div> |
| 1168 | </div> | 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 | // TimelineRowContent — the actual row component | 1255 | // TimelineRowContent — the actual row component |
| 1174 | // --------------------------------------------------------------------------- | 1256 | // --------------------------------------------------------------------------- |