1import { useEffect, useRef, useState } from "react";
2import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource";
3import type { CitationPhase } from "./citationProcedure";
4
5const CITATION_SCROLL_KEYS = new Set([
6 "ArrowUp",
7 "ArrowDown",
8 "PageUp",
9 "PageDown",
10 "Home",
11 "End",
12 " ",
13]);
14
15/** One activation of a citation; virtual row remounts and listeners share it. */
16interface CitationNavigation {
17 readonly target: AssistantCitationTarget;
18 /** Takes over from the live follow for this activation. */
19 readonly activate: () => void;
20 /** Stops this activation for good once another one (or none) replaces it. */
21 readonly retire: () => void;
22 /** Listens for user navigation that stops this activation's pending scroll. */
23 readonly attachDismissal: (viewport: HTMLElement | null) => () => void;
24}
25
26interface CitationSettlement {
27 /** Per activation, not per key: Back/Forward can restore an earlier key. */
28 readonly target: AssistantCitationTarget;
29 /** Dismissed citations also drop their highlight; completed ones keep it. */
30 readonly dismissed: boolean;
31}
32
33export function useAssistantCitationTarget({
34 request,
35 phase,
36 viewport,
37 onManualNavigation,
38}: {
39 request: AssistantCitationRequest | null;
40 phase: CitationPhase | null;
41 viewport: HTMLElement | null;
42 onManualNavigation: () => void;
43}) {
44 const [settlement, setSettlement] = useState<CitationSettlement | null>(null);
45 const [listLoaded, setListLoaded] = useState(false);
46 const onListLoad = () => setListLoaded(true);
47
48 // Rows and listeners mutate the activation, so it lives as long as its
49 // request; a target belongs to one request, so returning to an earlier key
50 // never reuses it.
51 const [navigation, setNavigation] = useState<CitationNavigation | null>(null);
52 let current = navigation;
53 if ((request?.key ?? null) !== (navigation?.target.key ?? null)) {
54 current = request ? createCitationNavigation(request) : null;
55 setNavigation(current);
56 }
57 function createCitationNavigation(next: AssistantCitationRequest): CitationNavigation {
58 let done = false;
59 const activationRef: AssistantCitationTarget["activationRef"] = {
60 current: { scrolled: false, dismissed: false },
61 };
62 const target: AssistantCitationTarget = {
63 ...next,
64 activationRef,
65 onComplete: () => {
66 done = true;
67 // The thread-open follow can run after the scroll started.
68 onManualNavigation();
69 setSettlement({ target, dismissed: false });
70 },
71 };
72 const activate = () => {
73 // The follow must yield before the citation row starts scrolling.
74 if (!done) onManualNavigation();
75 };
76 const retire = () => {
77 activationRef.current.dismissed = true;
78 activationRef.current.cancelScroll?.();
79 };
80 const attachDismissal = (citationViewport: HTMLElement | null) => {
81 // User navigation stops a pending citation scroll; Escape dismisses it outright.
82 const dismiss = (onlyPending: boolean) => {
83 const activation = activationRef.current;
84 if (activation.dismissed || (onlyPending && (activation.scrolled || done))) return;
85 activation.dismissed = true;
86 activation.cancelScroll?.();
87 if (!activation.scrolled && !done) onManualNavigation();
88 done = true;
89 setSettlement({ target, dismissed: true });
90 };
91 const onKeyDown = (event: KeyboardEvent) => {
92 if (event.key === "Escape") {
93 dismiss(false);
94 } else if (
95 CITATION_SCROLL_KEYS.has(event.key) &&
96 event.target instanceof Node &&
97 citationViewport?.contains(event.target)
98 ) {
99 dismiss(true);
100 }
101 };
102 const onWheel = (event: WheelEvent) => {
103 if (event.deltaY !== 0) dismiss(true);
104 };
105 const onNavigation = () => dismiss(true);
106 document.addEventListener("keydown", onKeyDown);
107 document.addEventListener("pointerdown", onNavigation, true);
108 citationViewport?.addEventListener("wheel", onWheel, { passive: true });
109 citationViewport?.addEventListener("touchmove", onNavigation, { passive: true });
110 return () => {
111 document.removeEventListener("keydown", onKeyDown);
112 document.removeEventListener("pointerdown", onNavigation, true);
113 citationViewport?.removeEventListener("wheel", onWheel);
114 citationViewport?.removeEventListener("touchmove", onNavigation);
115 };
116 };
117 return { target, activate, retire, attachDismissal };
118 }
119
120 // Retiring is one-way, so it runs when the activation is replaced rather than
121 // in a cleanup: StrictMode and <Activity> replay cleanups on a live activation.
122 // Unmounting needs no retire; the rows observing it unmount with the list.
123 const activatedRef = useRef<CitationNavigation | null>(null);
124 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- retires the replaced citation scroll and activates the new one, releasing the timeline follow
125 useEffect(() => {
126 if (activatedRef.current === current) return;
127 activatedRef.current?.retire();
128 activatedRef.current = current;
129 current?.activate();
130 }, [current]);
131 // Escape and pointerdown dismiss before the viewport mounts; the viewport
132 // listeners join once it exists.
133 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes document and viewport listeners that dismiss the pending citation scroll
134 useEffect(() => current?.attachDismissal(viewport), [current, viewport]);
135
136 const settled = current !== null && settlement?.target === current.target ? settlement : null;
137 const target =
138 current !== null && phase?._tag === "ready" && listLoaded && settled?.dismissed !== true
139 ? current.target
140 : null;
141 const positioning = current !== null && phase?._tag !== "failed" && settled === null;
142
143 return {
144 target,
145 positioning,
146 onListLoad,
147 alwaysRender:
148 target && positioning && phase?._tag === "ready" ? { keys: [phase.rowId] } : undefined,
149 };
150}