1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
3import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
4import { createEnvironmentSubscriptionAtomFamily } from "@t3tools/client-runtime/state/runtime";
5import {
6 AuthFilesystemReadScope,
7 DEFAULT_SERVER_SETTINGS,
8 EnvironmentId,
9 ProjectId,
10 type VcsListRefsResult,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Queue from "effect/Queue";
15import * as Stream from "effect/Stream";
16import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
17import { act, useLayoutEffect } from "react";
18import { createRoot } from "react-dom/client";
19import { expect, it, vi } from "vite-plus/test";
20import type { ComposerDraft } from "../../state/use-composer-drafts";
21
22const mocks = vi.hoisted(() => ({
23 projects: vi.fn(),
24 status: vi.fn(),
25 refs: vi.fn(),
26 readFile: vi.fn(),
27 session: vi.fn(),
28 presentation: vi.fn(),
29 registry: vi.fn(),
30 drafts: vi.fn(),
31 snapshot: vi.fn(),
32 createDraft: vi.fn(),
33 retargetDraft: vi.fn(),
34 writeSettings: vi.fn(),
35 writeText: vi.fn(),
36 serverConfig: vi.fn(),
37 openScratch: vi.fn(),
38 prepareTriage: vi.fn(),
39}));
40vi.mock("../../state/atom-registry", () => ({
41 appAtomRegistry: {
42 get: (atom: Atom.Atom<unknown>) => mocks.registry().get(atom),
43 set: (atom: Atom.Writable<unknown, unknown>, value: unknown) =>
44 mocks.registry().set(atom, value),
45 },
46}));
47vi.mock("../../state/entities", () => ({
48 useProjects: mocks.projects,
49 useThreadShells: () => [],
50 useEnvironmentServerConfig: () => mocks.serverConfig() ?? null,
51 useServerConfigs: () => new Map(),
52}));
53vi.mock("../../state/vcs", () => ({
54 vcsEnvironment: { status: mocks.status, listRefs: mocks.refs },
55}));
56vi.mock("../../state/projects", () => ({
57 projectEnvironment: { readFile: mocks.readFile, openScratch: {} },
58}));
59vi.mock("../../state/session", () => ({
60 environmentSession: { sessionStateAtom: mocks.session },
61}));
62vi.mock("../../state/presentation", () => ({
63 useEnvironmentPresentation: mocks.presentation,
64}));
65vi.mock("../../state/orchestration", () => ({ orchestrationEnvironment: {} }));
66vi.mock("../../state/threads", () => ({ olderSettledThreads: {}, threadFamilies: {} }));
67vi.mock("../../state/pull-requests", () => ({ composerPullRequests: {} }));
68vi.mock("../../state/olderSettledShelf", () => ({ useOlderSettledCount: () => 0 }));
69vi.mock("../../state/project-grouping", () => ({
70 useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }),
71}));
72vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() }));
73vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.openScratch }));
74vi.mock("../triage/usePrepareTriage", () => ({ usePrepareTriage: () => mocks.prepareTriage }));
75vi.mock("../../state/use-remote-environment-registry", () => ({
76 useSavedRemoteConnections: () => ({ savedConnectionsById: {} }),
77 useRemoteConnectionStatus: () => ({ connectedEnvironments: [] }),
78 setPendingConnectionError: vi.fn(),
79}));
80vi.mock("../../state/use-model-option-memory", () => ({
81 rememberModelOptions: vi.fn(),
82 withRememberedModelOptions: (value: unknown) => value,
83}));
84vi.mock("../../state/use-composer-drafts", () => ({
85 composerDraftsLoadedAtom: Atom.make(true),
86 useComposerDraft: mocks.drafts,
87 getComposerDraftSnapshot: mocks.snapshot,
88 createNewTaskDraft: mocks.createDraft,
89 retargetNewTaskDraft: mocks.retargetDraft,
90 updateComposerDraftSettings: mocks.writeSettings,
91 setComposerDraftText: mocks.writeText,
92 useStickyComposerModelSelection: () => null,
93 isNewTaskDraftKey: (key: string) => key.startsWith("new-task:"),
94 clearComposerDraft: vi.fn(),
95 appendComposerDraftAttachments: vi.fn(),
96 removeComposerDraftAttachment: vi.fn(),
97 replaceComposerDraftAttachments: vi.fn(),
98 setStickyComposerModelSelection: vi.fn(),
99 setComposerDraftContext: vi.fn(),
100 scheduleUnusedComposerAttachmentCleanup: vi.fn(),
101 isComposerDraftEmpty: (draft: ComposerDraft) =>
102 draft.text === "" && draft.attachments.length === 0,
103}));
104
105vi.mock("../../state/peerLinks", () => ({ useLinkedEnvironments: () => new Map() }));
106vi.mock("../../state/project-has-threads", () => ({ useProjectHasThreads: () => false }));
107vi.mock("../../state/workspace", () => ({
108 useConnectionsReady: () => true,
109 useWorkspaceEnvironments: () => [],
110}));
111vi.mock("../../state/shell", () => ({ useEnvironmentShellReadiness: () => ({}) }));
112vi.mock("../../lib/clock", () => ({ useNowMinuteMs: () => 0 }));
113
114import { NewTaskFlowProvider, useNewTaskFlow } from "./new-task-flow-provider";
115
116function project(environment: string, id: string): EnvironmentProject {
117 return {
118 environmentId: EnvironmentId.make(environment),
119 id: ProjectId.make(id),
120 title: id,
121 workspaceRoot: `/${id}`,
122 repositoryIdentity: null,
123 defaultModelSelection: null,
124 defaultThreadEnvMode: "worktree",
125 scripts: [],
126 createdAt: "2026-10-26T00:00:00Z",
127 updatedAt: "2026-10-26T00:00:00Z",
128 };
129}
130
131function setup() {
132 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
133 vi.useFakeTimers();
134 vi.clearAllMocks();
135 const registry = AtomRegistry.make();
136 mocks.registry.mockReturnValue(registry);
137 const projects = [project("one", "a"), project("one", "b"), project("two", "b")];
138 mocks.projects.mockReturnValue(projects);
139 mocks.presentation.mockReturnValue({
140 isReady: true,
141 presentation: { connection: { phase: "connected", error: null } },
142 });
143 const events: string[] = [];
144 let checkout = "main";
145 let checkoutIsRepo = true;
146 let holdStatus = false;
147 const emitters = new Map<string, (branch: string, isRepo?: boolean) => void>();
148 const runtime = Atom.runtime(
149 Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, {
150 followStream: (_environmentId, source) => source,
151 } as EnvironmentRegistry.EnvironmentRegistry["Service"]),
152 );
153 const status = createEnvironmentSubscriptionAtomFamily(runtime, {
154 label: "test:new-task-status",
155 idleTtlMs: 10_000,
156 subscribe: ({ cwd }: { cwd: string }) =>
157 Stream.callback<{ refName: string; isRepo: boolean }>((queue) =>
158 Effect.acquireRelease(
159 Effect.sync(() => {
160 events.push(`start:${cwd}`);
161 const emit = (refName: string, isRepo = true) =>
162 void Queue.offerUnsafe(queue, { refName, isRepo });
163 emitters.set(cwd, emit);
164 if (!holdStatus) emit(checkout, checkoutIsRepo);
165 }),
166 () => Effect.sync(() => void events.push(`stop:${cwd}`)),
167 ),
168 ),
169 });
170 mocks.status.mockImplementation(status);
171 const refs = Atom.family((key: string) =>
172 Atom.make(() => {
173 const [, cwd] = JSON.parse(key) as [string, string];
174 const result: VcsListRefsResult = {
175 refs: [{ name: "main", current: true, isDefault: true, worktreePath: cwd }],
176 isRepo: true,
177 hasPrimaryRemote: true,
178 nextCursor: null,
179 totalCount: 1,
180 };
181 return AsyncResult.success(result);
182 }).pipe(Atom.keepAlive),
183 );
184 mocks.refs.mockImplementation(({ environmentId, input }) =>
185 refs(JSON.stringify([environmentId, input.cwd])),
186 );
187 const session = Atom.make(
188 AsyncResult.success({ authenticated: true, scopes: [AuthFilesystemReadScope] }),
189 );
190 mocks.session.mockReturnValue(session);
191 const file = Atom.make(AsyncResult.success({ exists: true, contents: "{}" }));
192 mocks.readFile.mockReturnValue(file);
193
194 const empty: ComposerDraft = { text: "", attachments: [] };
195 const drafts = Atom.make<Record<string, ComposerDraft>>({}).pipe(Atom.keepAlive);
196 const snapshot = (key: string | null): ComposerDraft =>
197 (key === null ? undefined : registry.get(drafts)[key]) ?? empty;
198 const write = (key: string, patch: Partial<ComposerDraft>) =>
199 registry.set(drafts, { ...registry.get(drafts), [key]: { ...snapshot(key), ...patch } });
200 mocks.drafts.mockImplementation((key: string | null) => {
201 const all = useAtomValue(drafts);
202 return (key === null ? undefined : all[key]) ?? empty;
203 });
204 mocks.snapshot.mockImplementation(snapshot);
205 mocks.createDraft.mockImplementation((target) => {
206 const key = `new-task:${Object.keys(registry.get(drafts)).length}`;
207 write(key, { project: target });
208 return key;
209 });
210 mocks.retargetDraft.mockImplementation((key, target) =>
211 write(key, { project: target, workspaceSelection: undefined }),
212 );
213 mocks.writeSettings.mockImplementation(write);
214 mocks.writeText.mockImplementation((key, text) => write(key, { text }));
215 let current: ReturnType<typeof useNewTaskFlow>;
216 function Probe() {
217 const value = useNewTaskFlow();
218 useLayoutEffect(() => {
219 current = value;
220 });
221 return null;
222 }
223 const root = createRoot(document.createElement("div"));
224 return {
225 projects,
226 events,
227 checkout(branch: string, isRepo = true) {
228 checkout = branch;
229 checkoutIsRepo = isRepo;
230 },
231 holdStatus() {
232 holdStatus = true;
233 },
234 offline() {
235 mocks.presentation.mockReturnValue({
236 isReady: true,
237 presentation: { connection: { phase: "offline", error: null } },
238 });
239 holdStatus = true;
240 },
241 snapshot,
242 clearWorkspace(key: string) {
243 act(() => write(key, { workspaceSelection: undefined }));
244 },
245 get current() {
246 return current;
247 },
248 async render(active: boolean) {
249 await act(async () =>
250 root.render(
251 <RegistryContext.Provider value={registry}>
252 <NewTaskFlowProvider active={active} flowKey="test-flow">
253 <Probe />
254 </NewTaskFlowProvider>
255 </RegistryContext.Provider>,
256 ),
257 );
258 },
259 async emit(cwd: string, branch: string, isRepo = true) {
260 await act(async () => emitters.get(cwd)!(branch, isRepo));
261 },
262 async expireIdle() {
263 await act(async () => vi.advanceTimersByTime(11_000));
264 },
265 [Symbol.dispose]() {
266 act(() => root.unmount());
267 registry.dispose();
268 vi.useRealTimers();
269 },
270 };
271}
272
273it("owns a status stream only while the draft is active, retaining draft choices on reopen", async () => {
274 using screen = setup();
275 await screen.render(false);
276 expect(screen.current.selectedProject).toEqual(screen.projects[0]);
277 expect.soft([...screen.events]).toEqual([]);
278 expect(mocks.createDraft).not.toHaveBeenCalled();
279
280 await screen.render(true);
281 expect(screen.events).toEqual(["start:/a"]);
282 const draftKey = screen.current.draftKey!;
283 act(() => screen.current.setPrompt("keep this draft"));
284
285 await act(async () => screen.current.setProject(screen.projects[1]!));
286 expect(screen.current.prompt).toBe("keep this draft");
287 expect(screen.current.draftKey).toBe(draftKey);
288 expect(screen.events).toEqual(["start:/a", "start:/b"]);
289 await act(async () => screen.current.selectEnvironment(EnvironmentId.make("two")));
290 expect(screen.current.selectedProject).toEqual(screen.projects[2]);
291 expect(screen.snapshot(draftKey).project).toEqual({
292 environmentId: "two",
293 projectId: "b",
294 });
295 expect(screen.events).toEqual(["start:/a", "start:/b", "start:/b"]);
296 // A late old-project result cannot replace the current environment's checkout.
297 await screen.emit("/a", "stale-branch");
298 expect(screen.current.currentCheckoutBranchName).toBe("main");
299 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
300 act(() => screen.current.setWorkspaceMode("worktree"));
301 act(() =>
302 screen.current.selectBranch({
303 name: "feature",
304 current: false,
305 isDefault: false,
306 worktreePath: null,
307 }),
308 );
309 act(() => screen.current.setStartFromOrigin(true));
310 const chosenWorkspace = screen.snapshot(draftKey).workspaceSelection;
311 expect(chosenWorkspace).toEqual({
312 mode: "worktree",
313 branch: "feature",
314 worktreePath: null,
315 startFromOrigin: true,
316 });
317
318 await screen.render(false);
319 const settingsWrites = mocks.writeSettings.mock.calls.length;
320 await screen.expireIdle();
321 expect.soft(screen.events.filter((event) => event.startsWith("stop:"))).toHaveLength(3);
322 expect(mocks.writeSettings).toHaveBeenCalledTimes(settingsWrites);
323 expect(screen.snapshot(draftKey).workspaceSelection).toEqual(chosenWorkspace);
324 expect(screen.current.prompt).toBe("keep this draft");
325
326 screen.checkout("updated-checkout");
327 await screen.render(true);
328 expect.soft(screen.events.filter((event) => event.startsWith("start:"))).toHaveLength(4);
329 expect.soft(screen.current.currentCheckoutBranchName).toBe("updated-checkout");
330 expect(screen.current.prompt).toBe("keep this draft");
331 expect(screen.snapshot(draftKey).workspaceSelection).toEqual(chosenWorkspace);
332});
333
334it("does not persist a provisional workspace default after releasing inactive status", async () => {
335 using screen = setup();
336 await screen.render(true);
337 await screen.emit("/a", "main", false);
338 const draftKey = screen.current.draftKey!;
339 screen.clearWorkspace(draftKey);
340 mocks.writeSettings.mockClear();
341 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
342 await screen.render(false);
343 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
344 expect(mocks.writeSettings).not.toHaveBeenCalled();
345 await screen.expireIdle();
346 screen.holdStatus();
347 await screen.render(true);
348 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
349 await screen.emit("/a", "main", false);
350 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
351 expect(mocks.writeSettings).not.toHaveBeenCalled();
352});
353
354it("uses cached repository capability and branch defaults while offline", async () => {
355 using screen = setup();
356 screen.offline();
357 await screen.render(true);
358 expect(screen.current.currentCheckoutBranchName).toBeNull();
359 expect(screen.current.isGitRepoKnown).toBe(true);
360 expect(screen.current.workspaceMode).toBe("worktree");
361 expect(screen.current.selectedBranchName).toBe("main");
362 const draftKey = screen.current.draftKey!;
363 expect(screen.snapshot(draftKey).workspaceSelection).toBeUndefined();
364 await screen.render(false);
365 await screen.expireIdle();
366 await screen.render(true);
367 expect(screen.current.draftKey).toBe(draftKey);
368 expect(screen.current.selectedBranchName).toBe("main");
369});
370
371const playbookPrompt = (machine: string) =>
372 `Read [triage-playbook.md](/${machine}/triage-playbook.md) and follow it. `;
373
374/** A triage draft on machine one's No project home; machine two opens its own. */
375async function renderTriageDraft(screen: ReturnType<typeof setup>) {
376 mocks.serverConfig.mockReturnValue({
377 scratchWorkspaceRoot: "/a",
378 providers: [],
379 settings: DEFAULT_SERVER_SETTINGS,
380 });
381 mocks.openScratch.mockResolvedValue({ _tag: "Success", value: screen.projects[2] });
382 await screen.render(true);
383 act(() => screen.current.setPrompt(`${playbookPrompt("one")}It is slow.`));
384}
385
386it("takes a triage draft's session along when its machine changes", async () => {
387 using screen = setup();
388 await renderTriageDraft(screen);
389 mocks.prepareTriage.mockResolvedValue({
390 _tag: "Success",
391 value: { prompt: playbookPrompt("two") },
392 });
393
394 await act(async () => {
395 await screen.current.switchEnvironment(EnvironmentId.make("two"));
396 });
397
398 expect(screen.current.prompt).toBe(`${playbookPrompt("two")}It is slow.`);
399 expect(screen.current.selectedProject).toEqual(screen.projects[2]);
400});
401
402it.each([
403 [
404 "a project is picked",
405 1,
406 (flow: ReturnType<typeof useNewTaskFlow>, picked: EnvironmentProject) =>
407 flow.setProject(picked),
408 ],
409 [
410 "a send starts and ends",
411 0,
412 (flow: ReturnType<typeof useNewTaskFlow>) => {
413 flow.setSubmitting(true);
414 flow.setSubmitting(false);
415 },
416 ],
417] as const)(
418 "leaves a triage draft alone when %s while its new machine prepares",
419 async (_name, expectedProject, interrupt) => {
420 using screen = setup();
421 await renderTriageDraft(screen);
422 let finishPreparing!: (result: unknown) => void;
423 mocks.prepareTriage.mockReturnValue(
424 new Promise((resolve) => {
425 finishPreparing = resolve;
426 }),
427 );
428
429 let switching!: Promise<boolean>;
430 act(() => {
431 switching = screen.current.switchEnvironment(EnvironmentId.make("two"));
432 });
433 await act(async () => interrupt(screen.current, screen.projects[1]!));
434 let switched = true;
435 await act(async () => {
436 finishPreparing({ _tag: "Success", value: { prompt: playbookPrompt("two") } });
437 switched = await switching;
438 });
439
440 expect(switched).toBe(false);
441 expect(screen.current.prompt).toBe(`${playbookPrompt("one")}It is slow.`);
442 expect(screen.current.selectedProject).toEqual(screen.projects[expectedProject]);
443 },
444);