1import { describe, expect, it, vi } from "vite-plus/test";
2import type { 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 targetSettings = {
9 defaultThreadEnvMode: "local" as "local" | "worktree",
10 newWorktreesStartFromOrigin: false,
11 defaultModelSelection: null,
12 defaultRuntimeMode: "full-access" as RuntimeMode,
13 };
14 let storedDraft: {
15 readonly draftId: string;
16 readonly environmentId: string;
17 readonly promotedTo: null;
18 readonly threadId: string;
19 } | null = null;
20 const router = {
21 state: {
22 location: { href: "/" },
23 matches: [{ params: {} }],
24 },
25 navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => {
26 router.state.location.href = `/draft/${request.params.draftId}`;
27 }),
28 };
29 const draftStore = {
30 getComposerDraft: vi.fn(() => ({})),
31 getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft),
32 getDraftSession: vi.fn(() => null),
33 getDraftThread: vi.fn(() => null),
34 applyStickyState: vi.fn(),
35 setDraftThreadContext: vi.fn(),
36 setLogicalProjectDraftThreadId: vi.fn(),
37 setModelSelection: vi.fn(),
38 };
39
40 return {
41 completeProjectFileRead: (value: null) => completeProjectFileRead(value),
42 draftStore,
43 get projectFileRead() {
44 return projectFileRead;
45 },
46 get projectRemoved() {
47 return projectRemoved;
48 },
49 removeProject() {
50 projectRemoved = true;
51 },
52 get targetSettings() {
53 return targetSettings;
54 },
55 reset(
56 nextStoredDraft: typeof storedDraft,
57 workspaceDefaults = {
58 envMode: "local" as "local" | "worktree",
59 startFromOrigin: false,
60 },
61 ) {
62 storedDraft = nextStoredDraft;
63 projectRemoved = false;
64 targetSettings = {
65 defaultThreadEnvMode: workspaceDefaults.envMode,
66 newWorktreesStartFromOrigin: workspaceDefaults.startFromOrigin,
67 defaultModelSelection: null,
68 defaultRuntimeMode: "full-access",
69 };
70 router.state.location.href = "/";
71 router.navigate.mockClear();
72 draftStore.setDraftThreadContext.mockClear();
73 draftStore.setLogicalProjectDraftThreadId.mockClear();
74 projectFileRead = new Promise<null>((resolve) => {
75 completeProjectFileRead = resolve;
76 });
77 },
78 router,
79 };
80});
81
82vi.mock("@effect/atom-react", () => ({
83 useAtomValue: (atom: unknown) =>
84 atom === "primary-settings"
85 ? { newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin }
86 : new Map([
87 [
88 "environment-primary",
89 {
90 settings: {
91 ...testState.targetSettings,
92 newWorktreesStartFromOrigin: !testState.targetSettings.newWorktreesStartFromOrigin,
93 },
94 },
95 ],
96 ["environment-ssh", { settings: testState.targetSettings }],
97 ]),
98}));
99vi.mock("@t3tools/client-runtime/environment", () => ({
100 scopedProjectKey: () => "remote-project",
101 scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }),
102 scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }),
103}));
104vi.mock("@t3tools/contracts", () => ({
105 DEFAULT_RUNTIME_MODE: "default",
106 DEFAULT_SERVER_SETTINGS: {},
107}));
108vi.mock("@t3tools/shared/projectSettings", () => ({
109 // Environment settings pass through; the tests set project fields on the
110 // project record, which the hook still honors until the server folds them.
111 // With a file argument the env mode resolves like the real chain.
112 resolveProjectSettings: (
113 settings: Record<string, unknown>,
114 _projectId: unknown,
115 _project: unknown,
116 projectFile?: { defaultThreadEnvMode?: "local" | "worktree" } | null,
117 ) => ({
118 settings:
119 projectFile === undefined
120 ? settings
121 : {
122 ...settings,
123 defaultThreadEnvMode:
124 settings.defaultThreadEnvMode ?? projectFile?.defaultThreadEnvMode ?? "local",
125 },
126 sources: { defaultModelSelection: "environment", defaultThreadEnvMode: "environment" },
127 overrides: {},
128 }),
129 // The new-project rule has its own tests in projectSettings.test.ts.
130 resolveNewThreadEnvMode: (input: {
131 projectSettings: { settings: { defaultThreadEnvMode: "local" | "worktree" } };
132 }) => input.projectSettings.settings.defaultThreadEnvMode,
133}));
134vi.mock("@tanstack/react-router", () => ({
135 useParams: () => null,
136 useRouter: () => testState.router,
137}));
138vi.mock("react", () => ({
139 useCallback: <T>(callback: T) => callback,
140 useMemo: <T>(factory: () => T) => factory(),
141}));
142vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] }));
143vi.mock("../composerDraftStore", () => {
144 const useComposerDraftStore = Object.assign(() => null, {
145 getState: () => testState.draftStore,
146 });
147 return {
148 composerDraftHasUserContent: () => false,
149 markPromotedDraftThreadByRef: vi.fn(),
150 useComposerDraftStore,
151 };
152});
153vi.mock("../lib/chatThreadActions", async (importOriginal) => ({
154 ...(await importOriginal<typeof import("../lib/chatThreadActions")>()),
155 hasExplicitComposerModelSelection: () => false,
156 resolveNewThreadModelSelectionOverride: () => null,
157}));
158vi.mock("../lib/t3ProjectFileDefaults", () => ({
159 readT3ProjectFile: () => testState.projectFileRead,
160}));
161vi.mock("../lib/utils", () => ({
162 newDraftId: () => "draft-delayed",
163 newThreadId: () => "thread-delayed",
164}));
165vi.mock("../logicalProject", () => ({
166 deriveLogicalProjectKeyFromSettings: () => "remote-project",
167 getProjectOrderKey: () => "remote-project",
168 selectProjectGroupingSettings: () => ({}),
169}));
170vi.mock("../state/entities", () => {
171 const project = {
172 id: "project-remote",
173 environmentId: "environment-ssh",
174 workspaceRoot: "/remote/project",
175 defaultThreadEnvMode: null,
176 defaultModelSelection: null,
177 };
178 return {
179 readProject: () => (testState.projectRemoved ? null : project),
180 readProjects: () => (testState.projectRemoved ? [] : [project]),
181 readThreadShell: () => null,
182 readThreadShells: () => [],
183 useProjects: () => [],
184 useThread: () => null,
185 };
186});
187vi.mock("../state/server", () => ({
188 environmentServerConfigsAtom: {},
189 primaryServerSettingsAtom: "primary-settings",
190}));
191vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null }));
192vi.mock("../uiStateStore", () => ({
193 legacyProjectCwdPreferenceKey: () => "remote-project",
194 useUiStateStore: () => [],
195}));
196vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) }));
197
198import { useNewThreadHandler } from "./useHandleNewThread";
199
200describe.each([
201 ["new", null],
202 [
203 "reusable",
204 {
205 draftId: "draft-existing",
206 environmentId: "environment-ssh",
207 promotedTo: null,
208 threadId: "thread-existing",
209 },
210 ],
211])("useNewThreadHandler with a %s draft", (_, draft) => {
212 it.each(["approval-required", "auto-accept-edits", "auto", "full-access"] as const)(
213 "uses the target environment's %s permissions for new threads",
214 async (runtimeMode) => {
215 testState.reset(draft);
216 testState.targetSettings.defaultRuntimeMode = runtimeMode;
217 const projectRef = {
218 environmentId: "environment-ssh",
219 projectId: "project-remote",
220 } as never;
221 const pendingOpen = useNewThreadHandler()(projectRef);
222 testState.completeProjectFileRead(null);
223 const opened = await pendingOpen;
224
225 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
226 "remote-project",
227 projectRef,
228 opened!.draftId,
229 expect.objectContaining({ runtimeMode }),
230 );
231 },
232 );
233
234 it("abandons a delayed draft open when the user navigates elsewhere", async () => {
235 testState.reset(draft);
236 const openThread = useNewThreadHandler();
237 const pendingOpen = openThread(
238 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
239 { replace: true },
240 );
241
242 testState.router.state.location.href = "/usage";
243 testState.completeProjectFileRead(null);
244 await pendingOpen;
245
246 expect(testState.router.state.location.href).toBe("/usage");
247 expect(testState.router.navigate).not.toHaveBeenCalled();
248 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
249 });
250
251 it("abandons a delayed draft open when the project is removed meanwhile", async () => {
252 testState.reset(draft);
253 const pendingOpen = useNewThreadHandler()(
254 { environmentId: "environment-ssh", projectId: "project-remote" } as never,
255 { replace: true },
256 );
257
258 testState.removeProject();
259 testState.completeProjectFileRead(null);
260
261 await expect(pendingOpen).resolves.toBeNull();
262 expect(testState.router.navigate).not.toHaveBeenCalled();
263 expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled();
264 });
265
266 it.each([true, false])(
267 "uses the target environment's start-from-origin default of %s",
268 async (startFromOrigin) => {
269 testState.reset(draft, { envMode: "worktree", startFromOrigin });
270 const openThread = useNewThreadHandler();
271 const projectRef = {
272 environmentId: "environment-ssh",
273 projectId: "project-remote",
274 } as never;
275 const pendingOpen = openThread(projectRef);
276
277 testState.completeProjectFileRead(null);
278 const opened = await pendingOpen;
279
280 expect(opened).toEqual({
281 draftId: draft?.draftId ?? "draft-delayed",
282 threadId: draft?.threadId ?? "thread-delayed",
283 });
284 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
285 "remote-project",
286 projectRef,
287 opened!.draftId,
288 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
289 );
290 if (draft) {
291 expect(testState.draftStore.setDraftThreadContext).toHaveBeenCalledWith(
292 draft.draftId,
293 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
294 );
295 }
296 },
297 );
298
299 it.each([true, false])(
300 "preserves an explicit start-from-origin choice of %s",
301 async (startFromOrigin) => {
302 testState.reset(draft, { envMode: "worktree", startFromOrigin: !startFromOrigin });
303 const openThread = useNewThreadHandler();
304 const projectRef = {
305 environmentId: "environment-ssh",
306 projectId: "project-remote",
307 } as never;
308
309 const opened = await openThread(projectRef, { envMode: "worktree", startFromOrigin });
310
311 expect(testState.draftStore.setLogicalProjectDraftThreadId).toHaveBeenCalledWith(
312 "remote-project",
313 projectRef,
314 opened!.draftId,
315 expect.objectContaining({ envMode: "worktree", startFromOrigin }),
316 );
317 },
318 );
319});