1import {
2 createMemoryHistory,
3 createRootRoute,
4 createRoute,
5 createRouter,
6} from "@tanstack/react-router";
7import { describe, expect, it } from "vite-plus/test";
8import { scopeThreadRef } from "@t3tools/client-runtime/environment";
9import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads";
10import { ThreadId } from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
12import * as Option from "effect/Option";
13import { makeThreadProjectionFixture } from "./test-fixtures";
14
15import {
16 isThreadRoutePathname,
17 isThreadRouteSnapshotAuthoritative,
18 resolveActiveThreadRouteRef,
19 resolveThreadRouteRenderState,
20 resolveThreadRouteTarget,
21} from "./threadRoutes";
22
23describe("threadRoutes", () => {
24 it("matches only the thread's own route pathname", () => {
25 const ref = scopeThreadRef("env-1" as never, ThreadId.make("thread-1"));
26
27 expect(isThreadRoutePathname("/env-1/thread-1", ref)).toBe(true);
28 expect(isThreadRoutePathname("/env-1/thread-1/", ref)).toBe(true);
29 expect(isThreadRoutePathname("/new", ref)).toBe(false);
30 expect(isThreadRoutePathname("/env-1/thread-2", ref)).toBe(false);
31 expect(isThreadRoutePathname("/env-2/thread-1", ref)).toBe(false);
32 expect(isThreadRoutePathname("/draft/thread-1", ref)).toBe(false);
33 expect(isThreadRoutePathname("/env-1/thread-1/extra", ref)).toBe(false);
34
35 const encodedRef = scopeThreadRef("env space" as never, ThreadId.make("mcp:example/part"));
36 expect(isThreadRoutePathname("/env%20space/mcp%3Aexample%2Fpart", encodedRef)).toBe(true);
37 expect(isThreadRoutePathname("/env%20space/mcp%3Aexample%2Fpart/", encodedRef)).toBe(true);
38 expect(isThreadRoutePathname("/env%20space/mcp%3Aexample/part", encodedRef)).toBe(false);
39 expect(isThreadRoutePathname("/env%20space/mcp%253Aexample%2Fpart", encodedRef)).toBe(false);
40 expect(isThreadRoutePathname("/env%20space/mcp%ZZexample", encodedRef)).toBe(false);
41 expect(isThreadRoutePathname("/env%20space/mcp%3Aexample%2Fpart/extra", encodedRef)).toBe(
42 false,
43 );
44 expect(
45 isThreadRoutePathname(
46 "/env-1/mcp%253Aexample",
47 scopeThreadRef("env-1" as never, ThreadId.make("mcp%3Aexample")),
48 ),
49 ).toBe(true);
50 });
51
52 it("matches encoded MCP thread IDs from router navigation", async () => {
53 const root = createRootRoute();
54 const thread = createRoute({ getParentRoute: () => root, path: "$environmentId/$threadId" });
55 const newThread = createRoute({ getParentRoute: () => root, path: "new" });
56 const router = createRouter({
57 routeTree: root.addChildren([thread, newThread]),
58 history: createMemoryHistory({ initialEntries: ["/new"] }),
59 });
60 await router.load();
61 const ref = scopeThreadRef("local" as never, ThreadId.make("mcp:example"));
62 await router.navigate({
63 to: "/$environmentId/$threadId",
64 params: { environmentId: ref.environmentId, threadId: ref.threadId },
65 });
66 expect(router.state.location.pathname).toBe("/local/mcp%3Aexample");
67 expect(router.state.matches.at(-1)?.params).toEqual(ref);
68 expect(isThreadRoutePathname(router.state.location.pathname, ref)).toBe(true);
69 await router.navigate({ to: "/new" });
70 expect(isThreadRoutePathname(router.state.location.pathname, ref)).toBe(false);
71 });
72
73 it("resolves the backing thread while a draft route is being promoted", () => {
74 const target = resolveThreadRouteTarget({ draftId: "draft-1" });
75
76 expect(
77 resolveActiveThreadRouteRef(target, {
78 environmentId: "env-1" as never,
79 threadId: ThreadId.make("draft-thread"),
80 promotedTo: scopeThreadRef("env-2" as never, ThreadId.make("server-thread")),
81 }),
82 ).toEqual({
83 environmentId: "env-2",
84 threadId: "server-thread",
85 });
86 expect(
87 resolveActiveThreadRouteRef(
88 target,
89 null,
90 scopeThreadRef("env-2" as never, ThreadId.make("server-thread")),
91 ),
92 ).toEqual({ environmentId: "env-2", threadId: "server-thread" });
93 });
94
95 it("does not treat a draft's reserved thread ref as an active sidebar thread", () => {
96 const target = resolveThreadRouteTarget({ draftId: "draft-1" });
97
98 expect(
99 resolveActiveThreadRouteRef(target, {
100 environmentId: "env-1" as never,
101 threadId: ThreadId.make("draft-thread"),
102 promotedTo: null,
103 }),
104 ).toBeNull();
105 });
106
107 it.each([
108 { status: "cached", source: "server thread" },
109 { status: "cached", source: "draft" },
110 ] as const)(
111 "keeps available $source visible while the shell is $status",
112 ({ status, source }) => {
113 expect(
114 resolveThreadRouteRenderState({
115 bootstrapComplete: isThreadRouteSnapshotAuthoritative(status),
116 serverThreadExists: source === "server thread",
117 serverThreadDeleted: false,
118 unlistedThreadDetail: EMPTY_ENVIRONMENT_THREAD_STATE,
119 draftThreadExists: source === "draft",
120 }),
121 ).toBe("ready");
122 },
123 );
124
125 it("distinguishes bootstrap loading from a missing thread", () => {
126 expect(
127 resolveThreadRouteRenderState({
128 bootstrapComplete: false,
129 serverThreadExists: false,
130 serverThreadDeleted: false,
131 unlistedThreadDetail: { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" },
132 draftThreadExists: false,
133 }),
134 ).toBe("loading");
135 expect(
136 resolveThreadRouteRenderState({
137 bootstrapComplete: true,
138 serverThreadExists: false,
139 serverThreadDeleted: false,
140 unlistedThreadDetail: { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" },
141 draftThreadExists: false,
142 }),
143 ).toBe("missing");
144 });
145
146 it("opens an unlisted server thread once its detail arrives, without waiting for the shell", () => {
147 const route = {
148 bootstrapComplete: isThreadRouteSnapshotAuthoritative("live"),
149 serverThreadExists: false,
150 serverThreadDeleted: false,
151 draftThreadExists: false,
152 };
153
154 expect(
155 resolveThreadRouteRenderState({
156 ...route,
157 unlistedThreadDetail: EMPTY_ENVIRONMENT_THREAD_STATE,
158 }),
159 ).toBe("loading");
160 expect(
161 resolveThreadRouteRenderState({
162 ...route,
163 unlistedThreadDetail: {
164 ...EMPTY_ENVIRONMENT_THREAD_STATE,
165 status: "synchronizing",
166 },
167 }),
168 ).toBe("loading");
169 expect(
170 resolveThreadRouteRenderState({
171 ...route,
172 unlistedThreadDetail: {
173 ...EMPTY_ENVIRONMENT_THREAD_STATE,
174 status: "synchronizing",
175 error: Option.some("Connection interrupted"),
176 },
177 }),
178 ).toBe("loading");
179 expect(
180 resolveThreadRouteRenderState({
181 ...route,
182 unlistedThreadDetail: {
183 ...EMPTY_ENVIRONMENT_THREAD_STATE,
184 status: "live",
185 data: Option.some(makeThreadProjectionFixture()),
186 },
187 }),
188 ).toBe("ready");
189 });
190
191 it.each(["missing", "deleted", "archived"] as const)(
192 "redirects an unlisted thread only after detail confirms it is %s",
193 (outcome) => {
194 const route = {
195 bootstrapComplete: isThreadRouteSnapshotAuthoritative("live"),
196 serverThreadExists: false,
197 serverThreadDeleted: false,
198 draftThreadExists: false,
199 };
200 expect(
201 resolveThreadRouteRenderState({
202 ...route,
203 unlistedThreadDetail: {
204 ...EMPTY_ENVIRONMENT_THREAD_STATE,
205 status: "synchronizing",
206 },
207 }),
208 ).toBe("loading");
209
210 const projection = makeThreadProjectionFixture();
211 const removedAt = DateTime.makeUnsafe("2026-01-02T00:00:00Z");
212 expect(
213 resolveThreadRouteRenderState({
214 ...route,
215 unlistedThreadDetail:
216 outcome === "missing"
217 ? { ...EMPTY_ENVIRONMENT_THREAD_STATE, status: "deleted" }
218 : {
219 ...EMPTY_ENVIRONMENT_THREAD_STATE,
220 status: "live",
221 data: Option.some({
222 ...projection,
223 thread: {
224 ...projection.thread,
225 deletedAt: outcome === "deleted" ? removedAt : null,
226 archivedAt: outcome === "archived" ? removedAt : null,
227 },
228 }),
229 },
230 }),
231 ).toBe("missing");
232 },
233 );
234
235 it("waits for the live shell before redirecting a deleted thread", () => {
236 expect(
237 resolveThreadRouteRenderState({
238 bootstrapComplete: isThreadRouteSnapshotAuthoritative("cached"),
239 serverThreadExists: true,
240 serverThreadDeleted: true,
241 unlistedThreadDetail: EMPTY_ENVIRONMENT_THREAD_STATE,
242 draftThreadExists: false,
243 }),
244 ).toBe("loading");
245 });
246});