Skip to content
Source
Sign inSign up

useHandleNewThread.test.ts

407 lines13.8 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2import type { EnvironmentId, ProjectId, RuntimeMode } from "@t3tools/contracts";
3​
4const testState = vi.hoisted(() => {
5 let completeProjectFileRead: (value: null) => void = () => undefined;
6 let projectFileRead = Promise.resolve<null>(null);
7 let projectRemoved = false;
8 let connectedEnvironmentIds = ["environment-primary", "environment-ssh"];
9 let targetSettings = {
10 defaultThreadEnvMode: "local" as "local" | "worktree" | null,
11 newWorktreesStartFromOrigin: false,
12 defaultModelSelection: null,
13 defaultRuntimeMode: "full-access" as RuntimeMode,
14 };
15 let storedDraft: {
16 readonly draftId: string;
17 readonly environmentId: string;
18 readonly promotedTo: null;
19 readonly threadId: string;
20 } | null = null;
21 let openDraftRoute: { readonly kind: "draft"; readonly draftId: string } | null = null;
22 let draftsWithUserText = new Set<string>();
23 const router = {
24 state: {
25 location: { href: "/" },
26 matches: [{ params: {} }],
27 },
28 navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => {
29 router.state.location.href = `/draft/${request.params.draftId}`;
30 }),
31 };
32 const draftStore = {
33 getComposerDraft: vi.fn((draftId: string) => ({ draftId })),
34 getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft),
35 getDraftSession: vi.fn(() =>
36 openDraftRoute === null
37 ? null
38 : {
39 logicalProjectKey: "remote-project",
40 promotedTo: null,
41 threadId: "thread-open",
42 createdAt: "2026-01-01T00:00:00.000Z",
43 runtimeMode: "full-access",
44 interactionMode: "default",
45 },
46 ),
47 getDraftThread: vi.fn(() => null),
48 applyStickyState: vi.fn(),
49 setDraftThreadContext: vi.fn(),
50 setLogicalProjectDraftThreadId: vi.fn(),
51 setModelSelection: vi.fn(),
52 setPrompt: vi.fn(),
53 };
54​
55 return {
56 completeProjectFileRead: (value: null) => completeProjectFileRead(value),
57 draftStore,
58 get projectFileRead() {
59 return projectFileRead;
60 },
61 get projectRemoved() {
62 return projectRemoved;
63 },
64 get connectedEnvironmentIds() {
65 return connectedEnvironmentIds;
66 },
67 disconnect(environmentId: string) {
68 connectedEnvironmentIds = connectedEnvironmentIds.filter((id) => id !== environmentId);
69 },
70 removeProject() {
71 projectRemoved = true;
72 },
73 hasUserText: (draft: { readonly draftId?: string } | null | undefined) =>
74 draft?.draftId !== undefined && draftsWithUserText.has(draft.draftId),
75 giveUserText(draftId: string) {
76 draftsWithUserText = new Set([...draftsWithUserText, draftId]);
77 },
78 get openDraftRoute() {
79 return openDraftRoute;
80 },
81 openEmptyDraft(draftId: string) {
82 openDraftRoute = { kind: "draft", draftId };
83 },
84 get targetSettings() {
85 return targetSettings;
86 },
87 reset(
88 nextStoredDraft: typeof storedDraft,
89 workspaceDefaults = {
90 envMode: "local" as "local" | "worktree" | null,
91 startFromOrigin: false,
92 },
93 ) {
94 storedDraft = nextStoredDraft;
95 openDraftRoute = null;
96 draftsWithUserText = new Set();
97 projectRemoved = false;
98 connectedEnvironmentIds = ["environment-primary", "environment-ssh"];
99 targetSettings = {
100 defaultThreadEnvMode: workspaceDefaults.envMode,
101 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
102 defaultModelSelection: null,
103 defaultRuntimeMode: "full-access",
104 };
105 router.state.location.href = "/";
106 router.navigate.mockClear();
107 draftStore.setDraftThreadContext.mockClear();
108 draftStore.setLogicalProjectDraftThreadId.mockClear();
109 draftStore.setPrompt.mockClear();
110 projectFileRead = new Promise<null>((resolve) => {
111 completeProjectFileRead = resolve;
112 });
113 },
114 router,
115 };
116});
117​
118const testServerConfigs = () =>
119 new Map([
120 [
121 "environment-primary",
122 {
123 settings: {
124 ...testState.targetSettings,
125 newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin,
126 },
127 },
128 ],
129 ["environment-ssh", { settings: testState.targetSettings }],
130 ]);
131​
132vi.mock("../rpc/atomRegistry", () => ({
133 appAtomRegistry: {
134 get: (atom: unknown) =>
135 atom === "connected-environment-ids"
136 ? testState.connectedEnvironmentIds
137 : testServerConfigs(),
138 },
139}));
140​
141vi.mock("@effect/atom-react", () => ({
142 useAtomValue: (atom: unknown) =>
143 atom === "primary-settings"
144 ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin }
145 : testServerConfigs(),
146}));
147vi.mock("@t3tools/client-runtime/environment", () => ({
148 scopedProjectKey: (ref: { environmentId: string; projectId: string }) =>
149 `${ref.environmentId}:${ref.projectId}`,
150 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
151 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
152}));
153vi.mock("@t3tools/contracts", () => ({
154 DEFAULT_RUNTIME_MODE: "default",
155 DEFAULT_SERVER_SETTINGS: {},
156}));
157vi.mock("@t3tools/shared/projectSettings", () => ({
158 resolveProjectSettings: (
159 settings: Record<string, unknown>,
160 _projectId: unknown,
161 _project: unknown,
162 projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null,
163 ) => ({
164 settings:
165 projectFile === undefined
166 ? settings
167 : {
168 ...settings,
169 defaultThreadEnvMode:
170 settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local",
171 },
172 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
173 overrides: {},
174 }),
175 resolveNewThreadEnvMode: (input: {
176 projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } };
177 }) => input.projectSettings.settings.defaultThreadEnvMode,
178}));
179vi.mock("@tanstack/react-router", () => ({
180 useParams: () => null,
181 useRouter: () => testState.router,
182}));
183vi.mock("react", () => ({
184 useCallback: <T>(callback: T) => callback,
185 useMemo: <T>(factory: () => T) => factory(),
186}));
187vi.mock("../components/Sidebar.logic", () => ({
188 orderItemsByPreferredIds: ({ items }: { items: unknown[] }) => items,
189}));
190vi.mock("../composerDraftStore", () => {
191 const useComposerDraftStore = Object.assign(() => null, {
192 getState: () => testState.draftStore,
193 });
194 return {
195 composerDraftHasUserContent: (draft: { readonly draftId?: string } | null | undefined) =>
196 testState.hasUserText(draft),
197 markPromotedDraftThreadByRef: vi.fn(),
198 useComposerDraftStore,
199 };
200});
201vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
202 ...(await importOriginal<typeof import("../lib/chatThreadActions")>()),
203 hasExplicitComposerModelSelection: () => false,
204 resolveNewThreadModelSelectionOverride: () => null,
205}));
206vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null }));
207vi.mock("../lib/t3ProjectFileDefaults", () => ({
208 readT3ProjectFile: () => testState.projectFileRead,
209}));
210vi.mock("../lib/utils", () => ({
211 newDraftId: () => "draft-delayed",
212 newThreadId: () => "thread-delayed",
213}));
214vi.mock("@t3tools/client-runtime/state/project-grouping", () => ({
215 deriveLogicalProjectKeyFromSettings: () => "remote-project",
216 getProjectOrderKey: () => "remote-project",
217 selectProjectGroupingSettings: () => ({}),
218}));
219vi.mock("../state/entities", () => {
220 const project = {
221 id: "project-remote",
222 environmentId: "environment-ssh",
223 workspaceRoot: "/remote/project",
224 defaultThreadEnvMode: null,
225 defaultModelSelection: null,
226 };
227 return {
228 readProject: () => (testState.projectRemoved ? null : project),
229 readProjects: () => (testState.projectRemoved ? [] : [project]),
230 readThreadShell: () => null,
231 readThreadShells: () => [],
232 useProjects: () => [],
233 useThread: () => null,
234 };
235});
236vi.mock("../state/projects", () => ({ defaultProjectFallbackReadyAtom: () => true }));
237vi.mock("../state/olderSettledShelf", () => ({ readUnloadedThreadCount: () => 0 }));
238vi.mock("../state/presentation", () => ({
239 environmentSummaries: { connectedEnvironmentIdsAtom: "connected-environment-ids" },
240}));
241vi.mock("../state/server", () => ({
242 environmentServerConfigsAtom: {},
243 primaryServerSettingsAtom: "primary-settings",
244}));
245vi.mock("../threadRoutes", () => ({
246 resolveThreadRouteTarget: () => testState.openDraftRoute,
247}));
248vi.mock("../uiStateStore", () => ({
249 legacyProjectCwdPreferenceKey: () => "remote-project",
250 useUiStateStore: () => [],
251}));
252vi.mock("./useSettings", () => ({ getClientSettings: () => ({}) }));
253​
254import { resolveDefaultProjectRef, useNewThreadHandler } from "./useHandleNewThread";
255​
256describe.each([
257 ["new", null],
258 [
259 "reusable",
260 {
261 draftId: "draft-existing",
262 environmentId: "environment-ssh",
263 promotedTo: null,
264 threadId: "thread-existing",
265 },
266 ],
267])("useNewThreadHandler with a %s draft", (_, draft) => {
268 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
269 testState.reset(draft);
270 const openThread = useNewThreadHandler();
271 const pendingOpen = openThread(
272 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
273 { replace: true },
274 );
275​
276 testState.router.state.location.href = "/usage";
277 testState.completeProjectFileRead(null);
278 await pendingOpen;
279​
280 expect(testState.router.state.location.href).toBe("/usage");
281 expect(testState.router.navigate).not.toHaveBeenCalled();
282 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
283 });
284​
285 it("sets the prompt before the draft shows", async () => {
286 testState.reset(draft);
287 const pendingOpen = useNewThreadHandler()(
288 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
289 { prompt: "Read the playbook. " },
290 );
291​
292 testState.completeProjectFileRead(null);
293 const opened = await pendingOpen;
294​
295 expect(testState.draftStore.setPrompt).toHaveBeenCalledWith(
296 opened?.draftId,
297 "Read the playbook. ",
298 );
299 expect(testState.draftStore.setPrompt.mock.invocationCallOrder[0]).toBeLessThan(
300 testState.router.navigate.mock.invocationCallOrder[0] ?? 0,
301 );
302 });
303​
304 it("abandons a delayed draft open when the project is removed meanwhile", async () => {
305 testState.reset(draft);
306 const pendingOpen = useNewThreadHandler()(
307 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
308 { replace: true },
309 );
310​
311 testState.removeProject();
312 testState.completeProjectFileRead(null);
313​
314 await expect(pendingOpen).resolves.toBeNull();
315 expect(testState.router.navigate).not.toHaveBeenCalled();
316 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
317 });
318});
319​
320describe("useNewThreadHandler", () => {
321 it("keeps the text of a draft that gained some while it waited", async () => {
322 testState.reset(null);
323 const raced = {
324 draftId: "draft-raced",
325 environmentId: "environment-ssh",
326 promotedTo: null,
327 threadId: "thread-raced",
328 };
329 testState.draftStore.getDraftSessionByLogicalProjectKey
330 .mockReturnValueOnce(null)
331 .mockReturnValueOnce(raced as never);
332 testState.giveUserText("draft-raced");
333​
334 const opened = await useNewThreadHandler()(
335 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
336 { prompt: "Read the playbook. " },
337 );
338​
339 expect(opened?.draftId).toBe("draft-raced");
340 expect(testState.draftStore.setPrompt).not.toHaveBeenCalled();
341 });
342​
343 it("seeds the prompt into the empty draft that is already open", async () => {
344 testState.reset(null);
345 testState.openEmptyDraft("draft-open");
346​
347 const opened = await useNewThreadHandler()(
348 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
349 { prompt: "Read the playbook. " },
350 );
351​
352 expect(opened?.draftId).toBe("draft-open");
353 expect(testState.draftStore.setPrompt).toHaveBeenCalledWith(
354 "draft-open",
355 "Read the playbook. ",
356 );
357 });
358​
359 it("opens the draft without waiting on a project file from an offline machine", async () => {
360 testState.reset(null, { envMode: null, startFromOrigin: false });
361 testState.disconnect("environment-ssh");
362​
363 const opened = await useNewThreadHandler()({
364 environmentId: "environment-ssh",
365 projectId: "project-remote",
366 } as never);
367​
368 expect(opened).toEqual({ draftId: "draft-delayed", threadId: "thread-delayed" });
369 expect(testState.router.navigate).toHaveBeenCalledOnce();
370 });
371});
372​
373describe("default project selection", () => {
374 it("prefers the last used project in its environment and falls back when unavailable", () => {
375 const projects = [
376 { id: "project", environmentId: "first", workspaceRoot: "/first" },
377 { id: "project", environmentId: "last-used", workspaceRoot: "/last-used" },
378 ].map((project) => ({
379 ...project,
380 id: project.id as ProjectId,
381 environmentId: project.environmentId as EnvironmentId,
382 title: project.environmentId,
383 defaultModelSelection: null,
384 scripts: [],
385 createdAt: "2026-11-09T00:00:00.000Z",
386 updatedAt: "2026-11-09T00:00:00.000Z",
387 }));
388 expect(resolveDefaultProjectRef(projects, [], "last-used:project")).toEqual({
389 environmentId: "last-used",
390 projectId: "project",
391 });
392 expect(
393 resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project", false),
394 ).toBeNull();
395 expect(resolveDefaultProjectRef(projects.slice(0, 1), [], "last-used:project")).toEqual({
396 environmentId: "first",
397 projectId: "project",
398 });
399 expect(
400 resolveDefaultProjectRef(
401 [projects[0]!, { ...projects[1]!, archivedAt: "2026-11-09T00:00:00.000Z" }],
402 [],
403 "last-used:project",
404 ),
405 ).toEqual({ environmentId: "first", projectId: "project" });
406 });
407});