1import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts";
2import {
3 createMemoryHistory,
4 createRootRoute,
5 createRoute,
6 createRouter,
7} from "@tanstack/react-router";
8import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import { describe, expect, it } from "vite-plus/test";
10β
11import { selectActiveRightPanelSurface, useRightPanelStore } from "../rightPanelStore";
12import {
13 assistantCitationFromLocation,
14 assistantCitationNavigation,
15 revealSideChatCitation,
16} from "./assistantCitationNavigation";
17β
18const citation: AssistantCitation = {
19 version: 1,
20 environmentId: EnvironmentId.make("environment-one"),
21 threadId: ThreadId.make("thread-one"),
22 messageId: MessageId.make("assistant-one"),
23 text: "first line\n\tsecond line (with a #hash & spaces) π",
24 start: 12,
25 end: 59,
26 prefix: "Before:\n",
27 suffix: "\nAfter.",
28};
29β
30function createCitationRouter(initialEntry = "/environment-one/thread-one") {
31 const root = createRootRoute();
32 const thread = createRoute({ getParentRoute: () => root, path: "/$environmentId/$threadId" });
33 return createRouter({
34 routeTree: root.addChildren([thread]),
35 history: createMemoryHistory({ initialEntries: [initialEntry] }),
36 });
37}
38β
39describe("assistant citation navigation", () => {
40 it("preserves encoded quote whitespace through router navigation and reload", async () => {
41 const router = createCitationRouter();
42 await router.navigate(assistantCitationNavigation(citation, null));
43β
44 expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation);
45 let href = router.history.location.href;
46 for (let reload = 0; reload < 3; reload++) {
47 const reloaded = createCitationRouter(href);
48 await reloaded.load();
49 await reloaded.navigate({
50 to: "/$environmentId/$threadId",
51 params: { environmentId: citation.environmentId, threadId: citation.threadId },
52 hash: true,
53 replace: true,
54 });
55 href = reloaded.history.location.href;
56 expect(assistantCitationFromLocation(href)).toEqual(citation);
57 }
58 });
59β
60 it("reactivates an identical citation instead of deduplicating its navigation", async () => {
61 const router = createCitationRouter();
62 await router.navigate(assistantCitationNavigation(citation, null));
63 const previous = router.state.location;
64 await router.navigate(assistantCitationNavigation(citation, null));
65β
66 expect(router.state.location.href).toBe(previous.href);
67 expect(router.state.location.state.assistantCitationActivation).not.toBe(
68 previous.state.assistantCitationActivation,
69 );
70 expect(router.history.length).toBe(3);
71 expect(assistantCitationFromLocation(router.state.location.href)).toEqual(citation);
72 });
73β
74 it("opens a side chat citation as a tab of its parent thread", async () => {
75 resetRightPanels();
76 const router = createCitationRouter("/environment-one/thread-parent");
77 await router.navigate(assistantCitationNavigation(sideChatCitation, sideChatShell));
78 expect(router.state.location.pathname).toBe("/environment-one/thread-parent");
79 await revealAtRoute(router);
80β
81 expectSideChatRevealedOnParent(router);
82 });
83β
84 it("opens the side chat tab when arriving on the parent with its citation", async () => {
85 resetRightPanels();
86 const linked = createCitationRouter("/environment-one/thread-parent");
87 await linked.navigate(assistantCitationNavigation(sideChatCitation, sideChatShell));
88 const router = createCitationRouter(linked.history.location.href);
89 await router.load();
90 await revealAtRoute(router);
91β
92 expectSideChatRevealedOnParent(router);
93 });
94β
95 it("moves a citation activated before its side chat shell loaded onto the parent", async () => {
96 resetRightPanels();
97 const router = createCitationRouter("/environment-one/thread-parent");
98 await router.navigate(assistantCitationNavigation(sideChatCitation, null));
99 const activation = router.state.location.state.assistantCitationActivation;
100 expect(router.state.location.pathname).toBe("/environment-one/side-chat-one");
101β
102 await revealAtRoute(router);
103β
104 expectSideChatRevealedOnParent(router);
105 expect(router.state.location.state.assistantCitationActivation).toBe(activation);
106 expect(router.history.length).toBe(2);
107 });
108β
109 it("keeps quotes from forks on their own thread", () => {
110 const forkShell = { lineage: { ...sideChatShell.lineage, sideChat: false } };
111β
112 expect(assistantCitationNavigation(sideChatCitation, forkShell).params.threadId).toBe(
113 sideChatCitation.threadId,
114 );
115 });
116β
117 it("leaves an unrelated route alone", async () => {
118 resetRightPanels();
119 const router = createCitationRouter("/environment-one/thread-other");
120 await router.navigate({
121 ...assistantCitationNavigation(sideChatCitation, null),
122 params: { environmentId: citation.environmentId, threadId: ThreadId.make("thread-other") },
123 });
124 await revealAtRoute(router);
125β
126 expect(router.state.location.pathname).toBe("/environment-one/thread-other");
127 expect(useRightPanelStore.getState().byThreadKey).toEqual({});
128 });
129β
130 it("ignores a reveal for a side chat the location no longer quotes", async () => {
131 resetRightPanels();
132 const router = createCitationRouter("/environment-one/thread-parent");
133 await router.navigate(assistantCitationNavigation(sideChatCitation, sideChatShell));
134 await revealAtRoute(router, ThreadId.make("side-chat-two"));
135β
136 expect(useRightPanelStore.getState().byThreadKey).toEqual({});
137 });
138});
139β
140const parentThreadId = ThreadId.make("thread-parent");
141const sideChatCitation = { ...citation, threadId: ThreadId.make("side-chat-one") };
142const sideChatShell = {
143 lineage: {
144 parentThreadId,
145 relationshipToParent: "fork" as const,
146 rootThreadId: parentThreadId,
147 sideChat: true,
148 },
149};
150β
151function resetRightPanels() {
152 useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} });
153}
154β
155/** What the route-level ChatView does once the side chat shell is known. */
156function revealAtRoute(
157 router: ReturnType<typeof createCitationRouter>,
158 sideChatThreadId = sideChatCitation.threadId,
159) {
160 const { pathname, state } = router.state.location;
161 return revealSideChatCitation(
162 router,
163 scopeThreadRef(citation.environmentId, ThreadId.make(pathname.split("/").at(-1) ?? "")),
164 sideChatThreadId,
165 parentThreadId,
166 state.assistantCitationActivation ?? state.__TSR_key ?? "",
167 );
168}
169β
170function expectSideChatRevealedOnParent(router: ReturnType<typeof createCitationRouter>) {
171 expect(router.state.location.pathname).toBe("/environment-one/thread-parent");
172 expect(assistantCitationFromLocation(router.state.location.href)).toEqual(sideChatCitation);
173 expect(
174 selectActiveRightPanelSurface(
175 useRightPanelStore.getState().byThreadKey,
176 scopeThreadRef(citation.environmentId, parentThreadId),
177 ),
178 ).toMatchObject({ kind: "side-chat", threadId: sideChatCitation.threadId });
179}