| 1 | import type { LegendListRef } from "@legendapp/list/react"; |
| 2 | import type { RunId } from "@t3tools/contracts"; |
| 3 | import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; |
| 4 | import type { TimelineEntry } from "../../session-logic"; |
| 5 | import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; |
| 6 | import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource"; |
| 7 | import { toastManager } from "../ui/toast"; |
| 8 | |
| 9 | export interface CitationHistoryPage { |
| 10 | readonly loading: boolean; |
| 11 | readonly cursor?: string | null; |
| 12 | readonly onLoadEarlier: () => void; |
| 13 | } |
| 14 | |
| 15 | /** Fetch, unfold, and mount the source before its measured quote owns scrolling. */ |
| 16 | export function useAssistantCitationTarget({ |
| 17 | request, |
| 18 | entries, |
| 19 | rows, |
| 20 | listRef, |
| 21 | viewport, |
| 22 | historyLoading, |
| 23 | loadEarlier, |
| 24 | onExpandTurn, |
| 25 | onManualNavigation, |
| 26 | }: { |
| 27 | request: AssistantCitationRequest | null; |
| 28 | entries: ReadonlyArray<TimelineEntry>; |
| 29 | rows: ReadonlyArray<MessagesTimelineRow>; |
| 30 | listRef: RefObject<LegendListRef | null>; |
| 31 | viewport: HTMLElement | null; |
| 32 | historyLoading: boolean; |
| 33 | loadEarlier: CitationHistoryPage | null; |
| 34 | onExpandTurn: (runId: RunId) => void; |
| 35 | onManualNavigation: () => void; |
| 36 | }) { |
| 37 | const [ready, setReady] = useState<AssistantCitationTarget | null>(null); |
| 38 | const [finishedKey, setFinishedKey] = useState<string | null>(null); |
| 39 | const [listLoaded, setListLoaded] = useState(false); |
| 40 | const onListLoad = useCallback(() => setListLoaded(true), []); |
| 41 | const navigationRef = useRef<{ |
| 42 | target: AssistantCitationTarget; |
| 43 | requestedPages: Set<string>; |
| 44 | done: boolean; |
| 45 | } | null>(null); |
| 46 | |
| 47 | useEffect(() => { |
| 48 | if (navigationRef.current && navigationRef.current.target.key !== request?.key) { |
| 49 | navigationRef.current.target.activationRef.current.dismissed = true; |
| 50 | navigationRef.current.target.activationRef.current.cancelScroll?.(); |
| 51 | } |
| 52 | if (!request) { |
| 53 | navigationRef.current = null; |
| 54 | setReady(null); |
| 55 | setFinishedKey(null); |
| 56 | return; |
| 57 | } |
| 58 | if (navigationRef.current?.target.key !== request.key) { |
| 59 | const target: AssistantCitationTarget = { |
| 60 | ...request, |
| 61 | activationRef: { current: { scrolled: false, dismissed: false } }, |
| 62 | onComplete: () => { |
| 63 | if (navigationRef.current?.target !== target) return; |
| 64 | navigationRef.current.done = true; |
| 65 | // ChatView's thread-open effect can run after our initial opt-out. |
| 66 | onManualNavigation(); |
| 67 | setFinishedKey(target.key); |
| 68 | }, |
| 69 | }; |
| 70 | navigationRef.current = { |
| 71 | target, |
| 72 | requestedPages: new Set(), |
| 73 | done: false, |
| 74 | }; |
| 75 | setReady(null); |
| 76 | onManualNavigation(); |
| 77 | } |
| 78 | if (!viewport || historyLoading) return; |
| 79 | const navigation = navigationRef.current; |
| 80 | if (navigation.done || navigation.target.activationRef.current.dismissed) return; |
| 81 | const fail = (title: string, description: string) => { |
| 82 | navigation.done = true; |
| 83 | setFinishedKey(navigation.target.key); |
| 84 | toastManager.add({ type: "warning", title, description }); |
| 85 | }; |
| 86 | const source = entries.find( |
| 87 | (entry) => |
| 88 | entry.kind === "message" && entry.message.id === navigation.target.citation.messageId, |
| 89 | ); |
| 90 | if (!source) { |
| 91 | if (loadEarlier) { |
| 92 | if (loadEarlier.loading) return; |
| 93 | const cursor = loadEarlier.cursor ?? entries[0]?.id ?? "first"; |
| 94 | if (navigation.requestedPages.has(cursor) || navigation.requestedPages.size >= 20) { |
| 95 | fail( |
| 96 | "Could not load the cited response", |
| 97 | "Load earlier turns, then click the citation to try again. Your saved quote is unchanged.", |
| 98 | ); |
| 99 | return; |
| 100 | } |
| 101 | navigation.requestedPages.add(cursor); |
| 102 | loadEarlier.onLoadEarlier(); |
| 103 | return; |
| 104 | } |
| 105 | fail( |
| 106 | "The cited response is unavailable", |
| 107 | "It may have been removed. The selected text is still saved in your citation.", |
| 108 | ); |
| 109 | return; |
| 110 | } |
| 111 | if (source.kind !== "message" || source.message.role !== "assistant") { |
| 112 | fail( |
| 113 | "The citation does not refer to an assistant response", |
| 114 | "The selected text is still saved in your citation.", |
| 115 | ); |
| 116 | return; |
| 117 | } |
| 118 | const index = rows.findIndex( |
| 119 | (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId, |
| 120 | ); |
| 121 | if (index < 0) { |
| 122 | if (source.message.runId) onExpandTurn(source.message.runId); |
| 123 | return; |
| 124 | } |
| 125 | if (listLoaded && listRef.current) setReady(navigation.target); |
| 126 | }, [ |
| 127 | entries, |
| 128 | historyLoading, |
| 129 | listLoaded, |
| 130 | listRef, |
| 131 | loadEarlier, |
| 132 | onExpandTurn, |
| 133 | onManualNavigation, |
| 134 | request, |
| 135 | rows, |
| 136 | viewport, |
| 137 | ]); |
| 138 | |
| 139 | useEffect(() => { |
| 140 | if (!request) return; |
| 141 | const dismiss = (onlyPending: boolean) => { |
| 142 | const navigation = navigationRef.current; |
| 143 | if (!navigation || navigation.target.key !== request.key) return; |
| 144 | const activation = navigation.target.activationRef.current; |
| 145 | if (activation.dismissed || (onlyPending && (activation.scrolled || navigation.done))) return; |
| 146 | activation.dismissed = true; |
| 147 | activation.cancelScroll?.(); |
| 148 | if (!activation.scrolled && !navigation.done) onManualNavigation(); |
| 149 | navigation.done = true; |
| 150 | setReady(null); |
| 151 | setFinishedKey(request.key); |
| 152 | }; |
| 153 | const onKeyDown = (event: KeyboardEvent) => { |
| 154 | if (event.key === "Escape") { |
| 155 | dismiss(false); |
| 156 | } else if ( |
| 157 | viewport?.contains(event.target as Node) && |
| 158 | ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) |
| 159 | ) { |
| 160 | dismiss(true); |
| 161 | } |
| 162 | }; |
| 163 | const onWheel = (event: WheelEvent) => { |
| 164 | if (event.deltaY !== 0) dismiss(true); |
| 165 | }; |
| 166 | const onNavigation = () => dismiss(true); |
| 167 | document.addEventListener("keydown", onKeyDown); |
| 168 | document.addEventListener("pointerdown", onNavigation, true); |
| 169 | viewport?.addEventListener("wheel", onWheel, { passive: true }); |
| 170 | viewport?.addEventListener("touchmove", onNavigation, { passive: true }); |
| 171 | return () => { |
| 172 | document.removeEventListener("keydown", onKeyDown); |
| 173 | document.removeEventListener("pointerdown", onNavigation, true); |
| 174 | viewport?.removeEventListener("wheel", onWheel); |
| 175 | viewport?.removeEventListener("touchmove", onNavigation); |
| 176 | }; |
| 177 | }, [onManualNavigation, request, viewport]); |
| 178 | |
| 179 | const target = ready?.key === request?.key ? ready : null; |
| 180 | const positioning = request !== null && finishedKey !== request.key; |
| 181 | const sourceRow = |
| 182 | target && positioning |
| 183 | ? rows.find((row) => row.kind === "message" && row.message.id === target.citation.messageId) |
| 184 | : undefined; |
| 185 | return { |
| 186 | target, |
| 187 | positioning, |
| 188 | onListLoad, |
| 189 | alwaysRender: sourceRow ? { keys: [sourceRow.id] } : undefined, |
| 190 | }; |
| 191 | } |