Skip to content
Source
Sign inSign up

assistantCitationNavigation.ts

156 lines5.2 KB

1import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { pipe } from "effect/Function";
3import type {
4 AssistantCitation,
5 OrchestrationV2ThreadShell,
6 ScopedThreadRef,
7 ThreadId,
8} from "@t3tools/contracts";
9import { useRouter, type AnyRouter } from "@tanstack/react-router";
10import {
11 formatAssistantCitationHref,
12 parseAssistantCitationHref,
13} from "@t3tools/shared/assistantCitations";
14import * as Base64Url from "effect/encoding/Base64Url";
15import * as Result from "effect/Result";
16import { useEffect } from "react";
17​
18import { useRightPanelStore } from "../rightPanelStore";
19import { useQuietThreadShell } from "~/state/entities";
20import { randomUUID } from "./utils";
21​
22declare module "@tanstack/react-router" {
23 interface HistoryState {
24 assistantCitationActivation?: string;
25 }
27​
28const CITATION_HASH_PREFIX = "assistant-citation=";
29​
30/** Base64url keeps router hash normalization from decoding quote whitespace or source IDs. */
31function assistantCitationHash(citation: AssistantCitation) {
32 return `${CITATION_HASH_PREFIX}${Base64Url.encode(formatAssistantCitationHref(citation))}`;
34​
35export function assistantCitationFromLocation(href: string) {
36 const hashIndex = href.indexOf("#");
37 if (hashIndex === -1) return null;
38 const hash = href.slice(hashIndex + 1);
39 if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null;
40 try {
41 return pipe(
42 hash.slice(CITATION_HASH_PREFIX.length),
43 Base64Url.decodeString,
44 Result.getOrThrow,
45 parseAssistantCitationHref,
46 );
47 } catch {
48 return null;
49 }
51​
52/** Return paths drop the quote hash so going back does not reveal its source again. */
53export function withoutAssistantCitationHash(href: string) {
54 const hashIndex = href.indexOf("#");
55 if (hashIndex === -1 || !href.startsWith(CITATION_HASH_PREFIX, hashIndex + 1)) return href;
56 return href.slice(0, hashIndex);
58​
59type SourceThread = Pick<OrchestrationV2ThreadShell, "lineage"> | null;
60​
61function sideChatParentId(sourceThread: SourceThread) {
62 return sourceThread?.lineage.sideChat ? sourceThread.lineage.parentThreadId : null;
64​
65/** Side chat quotes open on their parent thread, where the side chat is a tab. */
66export function assistantCitationRoute(citation: AssistantCitation, sourceThread: SourceThread) {
67 return {
68 to: "/$environmentId/$threadId" as const,
69 params: {
70 environmentId: citation.environmentId,
71 threadId: sideChatParentId(sourceThread) ?? citation.threadId,
72 },
73 hash: assistantCitationHash(citation),
74 resetScroll: false,
75 };
77​
78/** A fresh activation lets the same link reveal its source again after dismissal. */
79export function assistantCitationNavigation(
80 citation: AssistantCitation,
81 sourceThread: SourceThread,
82) {
83 return {
84 ...assistantCitationRoute(citation, sourceThread),
85 state: { assistantCitationActivation: randomUUID() },
86 };
88​
89/**
90 * Reveals the location's side chat quote in its tab on `parentThreadId`, moving off the side
91 * chat's own route. Links, reloads and late-loading shells all land here, not only clicks.
92 */
93export function revealSideChatCitation(
94 router: Pick<AnyRouter, "navigate" | "state">,
95 routeThreadRef: ScopedThreadRef,
96 sideChatThreadId: ThreadId,
97 parentThreadId: ThreadId,
98 activation: string,
99) {
100 const { location } = router.state;
101 const citation = assistantCitationFromLocation(location.href);
102 if (
103 citation === null ||
104 citation.threadId !== sideChatThreadId ||
105 citation.environmentId !== routeThreadRef.environmentId ||
106 (routeThreadRef.threadId !== parentThreadId && routeThreadRef.threadId !== citation.threadId)
107 ) {
108 return;
109 }
110 useRightPanelStore
111 .getState()
112 .openSideChat(scopeThreadRef(citation.environmentId, parentThreadId), citation.threadId);
113 if (routeThreadRef.threadId === parentThreadId) return;
114 return router.navigate({
115 to: "/$environmentId/$threadId",
116 params: { environmentId: citation.environmentId, threadId: parentThreadId },
117 hash: true,
118 state: (state) => ({ ...state, assistantCitationActivation: activation }),
119 replace: true,
120 resetScroll: false,
121 });
123​
124export function useSideChatCitationReveal(
125 routeThreadRef: ScopedThreadRef | null,
126 citation: AssistantCitation | null,
127 activation: string,
128) {
129 const router = useRouter();
130 const sourceRef =
131 routeThreadRef && citation?.environmentId === routeThreadRef.environmentId
132 ? scopeThreadRef(citation.environmentId, citation.threadId)
133 : null;
134 const parentThreadId = sideChatParentId(useQuietThreadShell(sourceRef));
135 const environmentId = routeThreadRef?.environmentId;
136 const threadId = routeThreadRef?.threadId;
137 const sideChatThreadId = sourceRef?.threadId;
138 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- moves a side chat quote's router location and parent right panel once per activation, including after its shell loads
139 useEffect(() => {
140 if (
141 environmentId === undefined ||
142 threadId === undefined ||
143 sideChatThreadId === undefined ||
144 parentThreadId === null
145 ) {
146 return;
147 }
148 void revealSideChatCitation(
149 router,
150 scopeThreadRef(environmentId, threadId),
151 sideChatThreadId,
152 parentThreadId,
153 activation,
154 );
155 }, [router, environmentId, threadId, sideChatThreadId, parentThreadId, activation]);