1import { RegistryContext } from "@effect/atom-react";
2import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
3import * as Cause from "effect/Cause";
4import { act } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 create: vi.fn(),
10 navigate: vi.fn(),
11 toast: vi.fn(),
12 sidebarOpen: true,
13 onNavigated: vi.fn(() => {
14 state.sidebarOpen = false;
15 }),
16 router: { state: { location: { href: "/existing-chat" } } },
17 target: null as object | null,
18 draftEnvironmentId: null as string | null,
19 allowed: true,
20 connected: true,
21}));
22vi.mock("@tanstack/react-router", () => ({
23 useNavigate: () => state.navigate,
24 useRouter: () => state.router,
25 useParams: () => state.target,
26}));
27vi.mock("../state/environments", () => ({
28 usePrimaryEnvironmentId: () => "local",
29 useEnvironment: () => ({
30 connection: { phase: state.connected ? "connected" : "offline" },
31 serverConfig: { managedWorkspaces: true },
32 }),
33}));
34vi.mock("../composerDraftStore", () => ({ useComposerDraftStore: () => state.draftEnvironmentId }));
35vi.mock("../state/entities", () => ({ useProject: () => ({ id: "source" }) }));
36vi.mock("../state/query", () => ({
37 useEnvironmentQuery: () => ({ isSuccess: true, data: { projectId: "source" } }),
38}));
39vi.mock("../state/session", () => ({ useEnvironmentScope: () => true }));
40vi.mock("../state/managedWorkspaces", () => ({
41 managedWorkspaceStatus: () => null,
42 managedWorkspacePending: Atom.family(() => Atom.make(false)),
43 createManagedWorkspace: { permissionAtom: () => Atom.make(state.allowed) },
44}));
45vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.create }));
46vi.mock("../components/ui/toast", () => ({ toastManager: { add: state.toast } }));
47
48import { useNewWorkWorkspace } from "./useNewWorkWorkspace";
49
50let renderer: ReactTestRenderer;
51let registry: ReturnType<typeof AtomRegistry.make>;
52let actions: ReturnType<typeof useNewWorkWorkspace>[];
53function Probe({ onNavigated }: { onNavigated?: () => void }) {
54 actions.push(useNewWorkWorkspace(onNavigated));
55 return null;
56}
57const created = {
58 environmentId: "local",
59 projectId: "new-project",
60 threadId: "new-thread",
61 workspacePath: "/work/new",
62};
63async function mount() {
64 await act(() => {
65 renderer = create(
66 <RegistryContext.Provider value={registry}>
67 <Probe onNavigated={state.onNavigated} />
68 <Probe />
69 </RegistryContext.Provider>,
70 );
71 });
72}
73beforeEach(() => {
74 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
75 registry = AtomRegistry.make();
76 actions = [];
77 state.target = null;
78 state.draftEnvironmentId = null;
79 state.allowed = true;
80 state.connected = true;
81 state.router.state.location.href = "/existing-chat";
82 state.create.mockReset().mockResolvedValue(AsyncResult.success(created));
83 state.navigate.mockReset();
84 state.toast.mockReset();
85 state.sidebarOpen = true;
86 state.onNavigated.mockClear();
87});
88afterEach(async () => {
89 await act(() => renderer?.unmount());
90 registry.dispose();
91 vi.unstubAllGlobals();
92});
93
94it("shares the in-flight guard between header and command palette, then opens the returned thread", async () => {
95 let finish!: (value: unknown) => void;
96 state.create.mockReturnValue(
97 new Promise((resolve) => {
98 finish = resolve;
99 }),
100 );
101 await mount();
102 let first!: Promise<void>;
103 await act(async () => {
104 first = actions[0]!.run();
105 await actions[1]!.run();
106 });
107 expect(state.create).toHaveBeenCalledTimes(1);
108 await act(async () => {
109 finish(AsyncResult.success(created));
110 await first;
111 });
112 expect(state.navigate).toHaveBeenCalledExactlyOnceWith({
113 to: "/$environmentId/$threadId",
114 params: { environmentId: "local", threadId: "new-thread" },
115 });
116});
117
118it("releases the guard after failure so retry creates a workspace", async () => {
119 state.create.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("ws failed"))));
120 await mount();
121 await act(() => actions[0]!.run());
122 expect(state.navigate).not.toHaveBeenCalled();
123 expect(state.sidebarOpen).toBe(true);
124 expect(state.toast).toHaveBeenCalledWith(
125 expect.objectContaining({ type: "error", description: "ws failed" }),
126 );
127 await act(() => actions.at(-1)!.run());
128 expect(state.create).toHaveBeenCalledTimes(2);
129 expect(state.navigate).toHaveBeenCalledTimes(1);
130});
131
132it("keeps a newly selected chat open while creation finishes and offers an explicit Open action", async () => {
133 let finish!: (value: unknown) => void;
134 state.create.mockReturnValue(
135 new Promise((resolve) => {
136 finish = resolve;
137 }),
138 );
139 await mount();
140 let pending!: Promise<void>;
141 await act(() => {
142 pending = actions[0]!.run();
143 });
144 state.router.state.location.href = "/another-chat";
145 await act(async () => {
146 finish(AsyncResult.success(created));
147 await pending;
148 });
149 expect(state.navigate).not.toHaveBeenCalled();
150 expect(state.sidebarOpen).toBe(true);
151 const toast = state.toast.mock.calls[0]![0];
152 expect(toast).toMatchObject({ type: "success", description: "/work/new" });
153 await toast.actionProps.onClick();
154 expect(state.navigate).toHaveBeenCalledTimes(1);
155});
156
157it.each(["offline", "denied"])("does not create when %s", async (unavailable) => {
158 state.connected = unavailable !== "offline";
159 state.allowed = unavailable !== "denied";
160 await mount();
161 await act(() => actions[0]!.run());
162 expect(state.create).not.toHaveBeenCalled();
163});
164
165it("creates on the draft's environment instead of the primary environment", async () => {
166 state.target = { kind: "draft", draftId: "remote-draft" };
167 state.draftEnvironmentId = "remote";
168 await mount();
169 await act(() => actions[0]!.run());
170 expect(state.create).toHaveBeenCalledExactlyOnceWith({ environmentId: "remote", input: {} });
171});
172
173it("reveals the created workspace only after its navigation succeeds", async () => {
174 let finishNavigation!: () => void;
175 state.navigate.mockReturnValue(
176 new Promise<void>((resolve) => {
177 finishNavigation = resolve;
178 }),
179 );
180 await mount();
181 let pending!: Promise<void>;
182 await act(async () => {
183 pending = actions[0]!.run();
184 });
185 expect(state.navigate).toHaveBeenCalledTimes(1);
186 expect(state.sidebarOpen).toBe(true);
187 await act(async () => {
188 finishNavigation();
189 await pending;
190 });
191 expect(state.sidebarOpen).toBe(false);
192});
193
194it("keeps the mobile sidebar open if navigation to the created workspace fails", async () => {
195 state.navigate.mockRejectedValue(new Error("navigation failed"));
196 await mount();
197 await act(() => actions[0]!.run());
198 expect(state.sidebarOpen).toBe(true);
199 expect(state.toast).toHaveBeenCalledWith(
200 expect.objectContaining({ type: "error", description: "navigation failed" }),
201 );
202});