1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import { act, createElement } from "react";
9import { createRoot, type Root } from "react-dom/client";
10
11const refreshProviders = vi.hoisted(() => vi.fn());
12vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
13
14vi.mock("../../state/queries", () => ({
15 useComposerPathSearch: () => ({ entries: [], isPending: false }),
16 useComposerPullRequestSearch: () => ({ entries: [], isPending: false, error: null }),
17}));
18vi.mock("../../state/use-composer-drafts", () => ({
19 getComposerDraftSnapshot: vi.fn(),
20 readComposerDraftSelection: vi.fn(),
21 setComposerDraftContext: vi.fn(),
22}));
23vi.mock("../../lib/uuid", () => ({ uuidv4: () => "context-id" }));
24vi.mock("../../state/server", () => ({
25 serverEnvironment: { refreshProviders: Symbol("refreshProviders") },
26}));
27vi.mock("../../state/use-atom-command", () => ({
28 useAtomCommand: () => refreshProviders,
29}));
30
31import {
32 buildComposerSlashCommandItems,
33 resolveComposerCommandSelection,
34 useComposerCommandMenu,
35} from "./use-composer-command-menu";
36
37describe("mobile slash commands", () => {
38 const antigravity = {
39 driver: ProviderDriverKind.make("antigravity"),
40 showInteractionModeToggle: false,
41 slashCommands: [{ name: "plan", description: "Plan with Antigravity" }],
42 };
43
44 it.each([false, true])(
45 "keeps native /plan with legacy mode enabled=%s",
46 (allowInteractionMode) => {
47 const items = buildComposerSlashCommandItems({
48 query: "pl",
49 atMessageStart: true,
50 hasThread: true,
51 allowInteractionMode,
52 selectedProviderStatus: antigravity,
53 });
54
55 expect(items).toHaveLength(1);
56 expect(items[0]?.type).toBe("provider-slash-command");
57 const item = items[0];
58 if (!item) throw new Error("Expected the native plan command");
59 expect(
60 resolveComposerCommandSelection({
61 draftMessage: "/pl",
62 trigger: { rangeStart: 0, rangeEnd: 3 },
63 item,
64 allowInteractionMode,
65 }),
66 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
67 },
68 );
69
70 it("does not offer a native command inside the message", () => {
71 expect(
72 buildComposerSlashCommandItems({
73 query: "plan",
74 atMessageStart: false,
75 hasThread: false,
76 allowInteractionMode: true,
77 selectedProviderStatus: antigravity,
78 }),
79 ).toEqual([]);
80 });
81
82 it("still applies the T3 plan command for supported providers", () => {
83 const items = buildComposerSlashCommandItems({
84 query: "plan",
85 atMessageStart: true,
86 hasThread: true,
87 allowInteractionMode: true,
88 selectedProviderStatus: {
89 driver: ProviderDriverKind.make("codex"),
90 slashCommands: [],
91 },
92 });
93 const item = items[0];
94 if (!item) throw new Error("Expected the T3 plan command");
95 expect(
96 resolveComposerCommandSelection({
97 draftMessage: "/plan",
98 trigger: { rangeStart: 0, rangeEnd: 5 },
99 item,
100 allowInteractionMode: true,
101 }),
102 ).toEqual({ text: "", cursor: 0, interactionMode: "plan" });
103
104 // A provider switch can invalidate an open menu before a tap arrives.
105 expect(
106 resolveComposerCommandSelection({
107 draftMessage: "/plan",
108 trigger: { rangeStart: 0, rangeEnd: 5 },
109 item,
110 allowInteractionMode: false,
111 }),
112 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
113 });
114});
115
116describe("workspace command discovery retry", () => {
117 let root: Root;
118 const environmentId = EnvironmentId.make("test-environment");
119 const instanceId = ProviderInstanceId.make("claude");
120 const provider = {
121 instanceId,
122 driver: ProviderDriverKind.make("claude"),
123 enabled: true,
124 installed: true,
125 version: "1.0.0",
126 status: "ready",
127 auth: { status: "authenticated" },
128 checkedAt: "2026-01-01T00:00:00.000Z",
129 models: [],
130 slashCommands: [],
131 skills: [],
132 workspaceSnapshots: [
133 {
134 cwd: "/project-a",
135 checkedAt: "2026-01-01T00:00:00.000Z",
136 slashCommandsPending: true,
137 slashCommands: [{ name: "compact" }],
138 skills: [],
139 },
140 ],
141 } satisfies ServerProvider;
142
143 function Probe({ cwd, status = provider }: { cwd: string; status?: ServerProvider }) {
144 useComposerCommandMenu({
145 draftMessage: "/project",
146 ownerKey: null,
147 environmentId,
148 projectCwd: cwd,
149 selectedProviderStatus: status,
150 hasThread: false,
151 hasCompactableConversation: false,
152 onChangeDraftMessage: () => {},
153 });
154 return null;
155 }
156
157 beforeEach(() => {
158 vi.useFakeTimers();
159 refreshProviders.mockReset();
160 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [provider] } });
161 const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} };
162 const container = {
163 nodeType: 1,
164 tagName: "DIV",
165 namespaceURI: "http://www.w3.org/1999/xhtml",
166 ownerDocument: document,
167 addEventListener() {},
168 removeEventListener() {},
169 };
170 vi.stubGlobal("document", document);
171 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
172 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
173 root = createRoot(container as unknown as HTMLElement);
174 });
175
176 afterEach(async () => {
177 await act(async () => {
178 root.unmount();
179 });
180 vi.useRealTimers();
181 vi.unstubAllGlobals();
182 });
183
184 it("retries partial commands after the cooldown without editing the draft", async () => {
185 const recovered = {
186 ...provider,
187 workspaceSnapshots: [{ ...provider.workspaceSnapshots[0], slashCommandsPending: false }],
188 };
189 refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [provider] } });
190 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
191 await act(async () => {
192 root.render(createElement(Probe, { cwd: "/project-a" }));
193 });
194 expect(refreshProviders).toHaveBeenCalledTimes(1);
195 await act(() => vi.advanceTimersByTimeAsync(9_999));
196 expect(refreshProviders).toHaveBeenCalledTimes(1);
197 await act(() => vi.advanceTimersByTimeAsync(1));
198 expect(refreshProviders).toHaveBeenCalledTimes(2);
199 expect(refreshProviders).toHaveBeenLastCalledWith({
200 environmentId,
201 input: { instanceId, cwd: "/project-a" },
202 });
203 await act(() => vi.advanceTimersByTimeAsync(20_000));
204 expect(refreshProviders).toHaveBeenCalledTimes(2);
205 });
206
207 it.each(["pi", "acpRegistry"])(
208 "does not poll a healthy %s workspace without discovery",
209 async (driver) => {
210 const unsupported = {
211 ...provider,
212 driver: ProviderDriverKind.make(driver),
213 workspaceSnapshots: [],
214 };
215 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [unsupported] } });
216 await act(async () => {
217 root.render(createElement(Probe, { cwd: "/project-a", status: unsupported }));
218 });
219 expect(refreshProviders).toHaveBeenCalledTimes(1);
220 await act(() => vi.advanceTimersByTimeAsync(30_000));
221 expect(refreshProviders).toHaveBeenCalledTimes(1);
222 expect(vi.getTimerCount()).toBe(0);
223 },
224 );
225
226 it("arms the remaining cooldown when a concurrent scan publishes partial commands", async () => {
227 const missing = { ...provider, workspaceSnapshots: [] };
228 const recovered = {
229 ...provider,
230 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
231 ...snapshot,
232 slashCommandsPending: false,
233 })),
234 };
235 refreshProviders.mockResolvedValueOnce({ _tag: "Success", value: { providers: [missing] } });
236 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
237 await act(async () => {
238 root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
239 });
240 await act(() => vi.advanceTimersByTimeAsync(5_000));
241 expect(refreshProviders).toHaveBeenCalledTimes(1);
242 expect(vi.getTimerCount()).toBe(0);
243 await act(async () => {
244 root.render(createElement(Probe, { cwd: "/project-a", status: provider }));
245 });
246 await act(() => vi.advanceTimersByTimeAsync(4_999));
247 expect(refreshProviders).toHaveBeenCalledTimes(1);
248 await act(() => vi.advanceTimersByTimeAsync(1));
249 expect(refreshProviders).toHaveBeenCalledTimes(2);
250 await act(() => vi.advanceTimersByTimeAsync(20_000));
251 expect(refreshProviders).toHaveBeenCalledTimes(2);
252 });
253
254 it("does not retry when a concurrent scan publishes a complete workspace", async () => {
255 const missing = { ...provider, workspaceSnapshots: [] };
256 const recovered = {
257 ...provider,
258 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
259 ...snapshot,
260 slashCommandsPending: false,
261 })),
262 };
263 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [missing] } });
264 await act(async () => {
265 root.render(createElement(Probe, { cwd: "/project-a", status: missing }));
266 });
267 await act(() => vi.advanceTimersByTimeAsync(5_000));
268 await act(async () => {
269 root.render(createElement(Probe, { cwd: "/project-a", status: recovered }));
270 });
271 await act(() => vi.advanceTimersByTimeAsync(30_000));
272 expect(refreshProviders).toHaveBeenCalledTimes(1);
273 expect(vi.getTimerCount()).toBe(0);
274 });
275
276 it("retries a rejected refresh while published discovery remains pending", async () => {
277 const recovered = {
278 ...provider,
279 workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
280 ...snapshot,
281 slashCommandsPending: false,
282 })),
283 };
284 refreshProviders.mockRejectedValueOnce(new Error("Connection lost"));
285 refreshProviders.mockResolvedValue({ _tag: "Success", value: { providers: [recovered] } });
286 await act(async () => {
287 root.render(createElement(Probe, { cwd: "/project-a" }));
288 });
289 await act(() => vi.advanceTimersByTimeAsync(10_000));
290 expect(refreshProviders).toHaveBeenCalledTimes(2);
291 await act(() => vi.advanceTimersByTimeAsync(20_000));
292 expect(refreshProviders).toHaveBeenCalledTimes(2);
293 });
294
295 it("cancels the old workspace retry and does not duplicate an in-flight request", async () => {
296 await act(async () => {
297 root.render(createElement(Probe, { cwd: "/project-a" }));
298 });
299 refreshProviders.mockImplementationOnce(() => new Promise(() => {}));
300 await act(async () => {
301 root.render(createElement(Probe, { cwd: "/project-b" }));
302 });
303 expect(refreshProviders).toHaveBeenCalledTimes(2);
304 await act(() => vi.advanceTimersByTimeAsync(20_000));
305 expect(refreshProviders).toHaveBeenCalledTimes(2);
306 expect(refreshProviders).toHaveBeenLastCalledWith({
307 environmentId,
308 input: { instanceId, cwd: "/project-b" },
309 });
310 expect(vi.getTimerCount()).toBe(0);
311 });
312
313 it("cancels a scheduled retry when the composer unmounts", async () => {
314 await act(async () => {
315 root.render(createElement(Probe, { cwd: "/project-a" }));
316 });
317 await act(async () => {
318 root.unmount();
319 });
320 await act(() => vi.advanceTimersByTimeAsync(20_000));
321 expect(refreshProviders).toHaveBeenCalledTimes(1);
322 expect(vi.getTimerCount()).toBe(0);
323 });
324});