Open History to see the last commit
1import * as NodePath from "node:path";
2import * as NodeModule from "node:module";
3import * as React from "react";
4import { act, useLayoutEffect } from "react";
5import { createRoot } from "react-dom/client";
6import { expect, it, vi } from "vite-plus/test";
7
8const harness = vi.hoisted(() => ({
9 dispatch: (_action: unknown) => {},
10 getState: (): unknown => undefined,
11 getRoute: (): unknown => undefined,
12}));
13
14vi.mock("react-native", () => ({ Platform: { OS: "ios" } }));
15vi.mock("../layout/AdaptiveWorkspaceLayout", () => ({
16 useWorkspacePaneState: () => ({ fileInspector: { supported: true } }),
17}));
18vi.mock("@react-navigation/native", async () => {
19 const { createRequire } = await import("node:module");
20 const requireNavigation = createRequire(
21 createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
22 );
23 return {
24 StackActions: requireNavigation("@react-navigation/routers").StackActions,
25 useNavigation: () => ({ dispatch: harness.dispatch, getState: harness.getState }),
26 useRoute: () => harness.getRoute(),
27 };
28});
29
30import { useOpenThreadInspector } from "./thread-inspector-navigation";
31import { useOpenLinkedThread } from "./useOpenLinkedThread";
32
33const requireNavigation = NodeModule.createRequire(
34 NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"),
35);
36const { StackRouter, StackActions } = requireNavigation("@react-navigation/routers") as {
37 StackRouter: typeof import("@react-navigation/native").StackRouter;
38 StackActions: typeof import("@react-navigation/native").StackActions;
39};
40
41function setup(fromAgents: boolean, fullscreen = false) {
42 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
43 const router = StackRouter({});
44 const options = {
45 routeNames: ["Home", "Thread", "ThreadAgents", "ThreadInspectorFullscreen"],
46 routeParamList: {},
47 routeGetIdList: {},
48 };
49 let state = router.getInitialState(options);
50 const dispatch = (action: Parameters<typeof router.getStateForAction>[1]) => {
51 const next = router.getStateForAction(state, action, options);
52 if (!next) throw new Error(`Rejected ${action.type}`);
53 state = router.getRehydratedState(next, options);
54 };
55 harness.dispatch = (action) => dispatch(action as Parameters<typeof dispatch>[0]);
56 harness.getState = () => state;
57 harness.getRoute = () => state.routes.at(-1);
58 const source = { environmentId: "remote-environment", threadId: "parent" };
59 dispatch(StackActions.push("Thread", { ...source, prompt: "retained prompt" }));
60 const parentKey = state.routes.at(-1)!.key;
61 if (fullscreen)
62 dispatch(StackActions.push("ThreadInspectorFullscreen", { ...source, threadId: "child" }));
63 if (fromAgents)
64 dispatch(
65 StackActions.push("ThreadAgents", fullscreen ? { ...source, threadId: "child" } : source),
66 );
67 let openInspector: ReturnType<typeof useOpenThreadInspector>;
68 let openLinkedThread: ReturnType<typeof useOpenLinkedThread>;
69 function Probe() {
70 const open = useOpenThreadInspector();
71 const openLinked = useOpenLinkedThread();
72 useLayoutEffect(() => {
73 openInspector = open;
74 openLinkedThread = openLinked;
75 });
76 return null;
77 }
78 const root = createRoot(document.createElement("div"));
79 act(() => root.render(<Probe />));
80 return {
81 source,
82 parentKey,
83 dispatch,
84 get state() {
85 return state;
86 },
87 open(target: Parameters<typeof openInspector>[1], from = source) {
88 let opened = false;
89 act(() => {
90 opened = openInspector(from, target);
91 });
92 return opened;
93 },
94 openLinked(threadId: string) {
95 act(() => openLinkedThread({ environmentId: source.environmentId, threadId }));
96 },
97 [Symbol.dispose]() {
98 act(() => root.unmount());
99 },
100 };
101}
102
103it("opens a subagent from the Agents sheet without replacing the parent thread", () => {
104 using screen = setup(true);
105 const inspector = {
106 type: "thread",
107 environmentId: screen.source.environmentId,
108 threadId: "child",
109 } as const;
110 screen.open(inspector);
111 expect(screen.state.routes.map((route) => route.name)).toEqual(["Home", "Thread"]);
112 expect(screen.state.routes.at(-1)).toMatchObject({
113 key: screen.parentKey,
114 params: { ...screen.source, prompt: "retained prompt", inspector },
115 });
116 screen.dispatch(StackActions.push("ThreadInspectorFullscreen", inspector));
117 screen.dispatch(StackActions.pop());
118 expect(screen.state.routes.at(-1)).toMatchObject({
119 key: screen.parentKey,
120 params: { ...screen.source, inspector },
121 });
122});
123
124it("opens a completion link in the parent inspector", () => {
125 using screen = setup(false);
126 screen.openLinked("child");
127 expect(screen.state.routes).toHaveLength(2);
128 expect(screen.state.routes.at(-1)).toMatchObject({
129 key: screen.parentKey,
130 params: {
131 ...screen.source,
132 inspector: { type: "thread", environmentId: screen.source.environmentId, threadId: "child" },
133 },
134 });
135});
136
137it("switches between subagents and devices in the same parent route", () => {
138 using screen = setup(false);
139 screen.open({ type: "thread", environmentId: screen.source.environmentId, threadId: "child" });
140 const inspector = { type: "device", ...screen.source } as const;
141 screen.open(inspector);
142 expect(screen.state.routes).toHaveLength(2);
143 expect(screen.state.routes.at(-1)).toMatchObject({
144 key: screen.parentKey,
145 params: { ...screen.source, inspector },
146 });
147});
148
149it.each(["thread", "device"] as const)(
150 "keeps the parent when a maximized child opens a %s preview",
151 (type) => {
152 using screen = setup(false, true);
153 const child = { ...screen.source, threadId: "child" };
154 const originalState = screen.state;
155 expect(screen.open({ type, ...child, threadId: "grandchild" }, child)).toBe(false);
156 expect(screen.state).toEqual(originalState);
157 screen.dispatch(StackActions.pop());
158 expect(screen.state.routes.at(-1)).toMatchObject({
159 key: screen.parentKey,
160 params: screen.source,
161 });
162 },
163);
164
165it("keeps the parent when the maximized child's Agents sheet opens another child", () => {
166 using screen = setup(true, true);
167 const child = { ...screen.source, threadId: "child" };
168 const originalState = screen.state;
169 expect(screen.open({ type: "thread", ...child, threadId: "grandchild" }, child)).toBe(false);
170 expect(screen.state).toEqual(originalState);
171});
172
173it("keeps a focused inspector registered across renders and updates its content", () => {
174 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
175 const requireMobile = NodeModule.createRequire(import.meta.url);
176 const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() };
177 let focused = true;
178 let activeRender: (() => React.ReactNode) | undefined;
179 const deactivate = vi.fn(() => {
180 activeRender = undefined;
181 });
182 const registerWorkspaceInspector = vi.fn(
183 (content: { readonly panes: ReadonlyArray<{ readonly render: () => React.ReactNode }> }) => {
184 activeRender = content.panes[0]?.render;
185 return deactivate;
186 },
187 );
188 const navigation = {
189 isFocused: () => focused,
190 addListener(event: "focus" | "blur", listener: () => void) {
191 listeners[event].add(listener);
192 return () => {
193 listeners[event].delete(listener);
194 };
195 },
196 };
197 const navigationContext = React.createContext(navigation);
198 const routeContext = React.createContext({ key: "file" });
199 const babel = requireMobile("@babel/core") as {
200 transformFileSync(file: string, options: Record<string, unknown>): { code: string };
201 };
202 const compiled = babel.transformFileSync(
203 NodePath.resolve(__dirname, "../layout/AdaptiveWorkspaceLayout.tsx"),
204 {
205 babelrc: false,
206 configFile: false,
207 presets: [
208 [
209 requireMobile.resolve("babel-preset-expo"),
210 { unstable_transformImportMeta: true, enableBabelRuntime: false },
211 ],
212 ],
213 caller: {
214 name: "metro",
215 bundler: "metro",
216 platform: "ios",
217 isDev: false,
218 isServer: false,
219 isNodeModule: false,
220 supportsStaticESM: false,
221 supportsReactCompiler: true,
222 },
223 },
224 );
225 const exports: {
226 Inspector?: (props: {
227 readonly content: {
228 readonly active: string;
229 readonly panes: ReadonlyArray<{ readonly key: string; readonly render: () => string }>;
230 };
231 }) => null;
232 } = {};
233 const load = (name: string) => {
234 if (name === "react" || name.startsWith("react/")) return requireMobile(name);
235 if (name === "./adaptive-workspace-context")
236 return {
237 useAdaptiveWorkspaceLayout: () => ({ registerWorkspaceInspector }),
238 };
239 if (name === "@react-navigation/native")
240 return {
241 NavigationContext: navigationContext,
242 NavigationRouteContext: routeContext,
243 };
244 return {};
245 };
246 new Function("require", "exports", compiled.code)(load, exports);
247 const Inspector = exports.Inspector!;
248 const contents = new Map<string, Parameters<typeof Inspector>[0]["content"]>();
249 function Screen({ content }: { content: string | undefined }) {
250 if (content === undefined) return null;
251 let panes = contents.get(content);
252 if (!panes) {
253 panes = { active: "main", panes: [{ key: "main", render: () => content }] };
254 contents.set(content, panes);
255 }
256 return <Inspector content={panes} />;
257 }
258 const root = createRoot(document.createElement("div"));
259 using _root = { [Symbol.dispose]: () => act(() => root.unmount()) };
260 const render = (content: string | undefined) =>
261 act(() => root.render(<Screen content={content} />));
262 const first = "first";
263 render(first);
264 registerWorkspaceInspector.mockClear();
265 deactivate.mockClear();
266 render(first);
267 render(first);
268 expect(registerWorkspaceInspector).not.toHaveBeenCalled();
269 expect(deactivate).not.toHaveBeenCalled();
270 const second = "second";
271 render(second);
272 expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1);
273 const paneContainer = document.createElement("div");
274 const paneRoot = createRoot(paneContainer);
275 using _pane = { [Symbol.dispose]: () => act(() => paneRoot.unmount()) };
276 act(() => paneRoot.render(activeRender?.()));
277 expect(paneContainer.textContent).toBe("second");
278 const emit = (event: "focus" | "blur") =>
279 act(() => {
280 focused = event === "focus";
281 for (const listener of listeners[event]) listener();
282 });
283 emit("blur");
284 expect(deactivate).toHaveBeenCalledTimes(1);
285 registerWorkspaceInspector.mockClear();
286 render(first);
287 expect(registerWorkspaceInspector).not.toHaveBeenCalled();
288 emit("focus");
289 expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1);
290 emit("focus");
291 expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1);
292 render(undefined);
293 expect(activeRender).toBeUndefined();
294 render(second);
295 expect(activeRender).toBeDefined();
296 act(() => root.unmount());
297 expect(activeRender).toBeUndefined();
298 expect(listeners.focus.size + listeners.blur.size).toBe(0);
299});