Open History to see the last commit
1import { useSyncExternalStore } from "react";
2import { Appearance, Keyboard, View } from "react-native";
3import {
4 CommonActions,
5 StackActions,
6 type NavigationAction,
7 type NavigationState,
8} from "@react-navigation/native";
9import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
10import { AsyncResult } from "effect/reactivity";
11import { requireOptionalNativeModule } from "expo";
12
13import { connectPairingUrl } from "../../connection/onboarding";
14import {
15 createMobileThemePairPatch,
16 resolveMobileThemeIds,
17 type MobileThemeId,
18} from "../../lib/mobileTheme";
19import { rootNavigationRef } from "../../lib/rootNavigation";
20import { appAtomRegistry } from "../../state/atom-registry";
21import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
22import { environmentProjects } from "../../state/projects";
23import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
24import { environmentThreadShells } from "../../state/threads";
25import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
26import { workspaceConnections } from "../../state/workspace";
27import { resolveThemeAppearance } from "../settings/appearance/AppearancePreferencesProvider";
28import {
29 applyNativeShowcaseOrientation,
30 getNativeShowcaseOrientation,
31 getNativeShowcasePairingUrls,
32 getNativeShowcaseScene,
33 getNativeShowcaseTheme,
34 markNativeShowcaseReady,
35 type ShowcaseScene,
36} from "./nativeShowcaseScene";
37import { buildShowcaseAgentActivity } from "./showcaseAgentActivity";
38import {
39 isShowcaseThemeApplied,
40 SHOWCASE_THREAD_ID,
41 showcaseReadinessKey,
42 showcaseSceneOnScreen,
43 shouldNavigateToShowcaseScene,
44 type ShowcaseNavigationTarget,
45} from "./showcaseCaptureModel";
46import {
47 buildShowcasePendingTasks,
48 SHOWCASE_PENDING_TASK_DEFINITIONS,
49} from "./showcasePendingTasks";
50import {
51 clearShowcaseRenderSignal,
52 getShowcaseRenderSnapshot,
53 subscribeToShowcaseRenderSignal,
54} from "./showcaseRenderSignal";
55import { retryShowcaseOperation } from "./showcaseRetry";
56import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity";
57import { settingsNavigationState } from "../settings/settings-navigation";
58import { selectWorkspaceDetail } from "../layout/workspace/workspace-detail-selection";
59
60const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
61// Native launch requests (pairing links, scene switches) have no change event.
62const NATIVE_POLL_MS = 250;
63const READY_SETTLE_MS = 500;
64
65const readySceneListeners = new Set<() => void>();
66let readyScene: ShowcaseScene | null = null;
67
68function setReadyScene(scene: ShowcaseScene | null): void {
69 if (readyScene === scene) return;
70 readyScene = scene;
71 for (const listener of readySceneListeners) listener();
72}
73
74function subscribeToReadyScene(listener: () => void): () => void {
75 readySceneListeners.add(listener);
76 return () => readySceneListeners.delete(listener);
77}
78
79function getReadyScene(): ShowcaseScene | null {
80 return readyScene;
81}
82
83interface Run {
84 cancelled: boolean;
85 done: boolean;
86}
87
88function cancelRun(run: Run | null): null {
89 if (run) run.cancelled = true;
90 return null;
91}
92
93function showcaseNavigationAction(
94 scene: ShowcaseScene,
95 environmentId: string,
96 rootState: NavigationState,
97): NavigationAction {
98 const params = { environmentId, threadId: SHOWCASE_THREAD_ID };
99 switch (scene) {
100 case "threads":
101 return StackActions.popToTop();
102 case "agent-activity":
103 return CommonActions.reset({ index: 0, routes: [{ name: "Home" }] });
104 case "environments":
105 return CommonActions.reset(settingsNavigationState(rootState, "SettingsEnvironments"));
106 case "terminal":
107 return CommonActions.navigate("ThreadTerminal", { ...params, terminalId: "term-1" });
108 case "review":
109 return CommonActions.navigate("ThreadReview", params);
110 case "thread":
111 return selectWorkspaceDetail({ name: "Thread", params });
112 }
113}
114
115/**
116 * Drives a showcase capture: pairs servers, seeds the fixture, applies theme and
117 * orientation, and navigates to each scene the native harness requests. Inputs
118 * are read from their stores on each step, so nothing depends on render timing.
119 */
120export function installShowcaseCapture(
121 pathnameFromState: (state: NavigationState) => string,
122): () => void {
123 if (!SHOWCASE_ENABLED) return () => {};
124
125 let disposed = false;
126 let launchRequestRead = false;
127 let requestedTheme: MobileThemeId | null = null;
128 let orientationSettled = false;
129 let requestedScene: ShowcaseScene | null = null;
130 let pendingTasks: Run | null = null;
131 let agentActivity: Run | null = null;
132 let lastNavigation: ShowcaseNavigationTarget | null = null;
133 let readiness: { key: string; cancel: () => void } | null = null;
134 const isCancelled = (run?: Run) => () => disposed || run?.cancelled === true;
135
136 const devMenu = requireOptionalNativeModule<{
137 closeMenu(): void;
138 setToolsButtonVisible(visible: boolean): void;
139 }>("ExpoDevMenu");
140 // The dev menu can open after bundle loading and block orientation requests.
141 const devMenuTimer = setInterval(() => {
142 devMenu?.closeMenu();
143 devMenu?.setToolsButtonVisible(false);
144 }, 1_000);
145
146 const orientation = getNativeShowcaseOrientation();
147 if (orientation === null) {
148 orientationSettled = true;
149 } else {
150 void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), {
151 isCancelled: isCancelled(),
152 }).then((applied) => {
153 if (!applied) return;
154 orientationSettled = true;
155 evaluate();
156 });
157 }
158
159 const pair = (pairingUrls: ReadonlyArray<string>) => {
160 for (const pairingUrl of pairingUrls) {
161 void retryShowcaseOperation(
162 async () =>
163 AsyncResult.isSuccess(
164 await runAtomCommand(
165 appAtomRegistry,
166 connectPairingUrl,
167 { pairingUrl },
168 { reportFailure: false },
169 ),
170 ),
171 { isCancelled: isCancelled() },
172 );
173 }
174 };
175
176 const readNativeRequests = () => {
177 if (!launchRequestRead) {
178 const pairingUrls = getNativeShowcasePairingUrls();
179 if (pairingUrls.length > 0) {
180 launchRequestRead = true;
181 requestedTheme = getNativeShowcaseTheme();
182 pair(pairingUrls);
183 }
184 }
185 const scene = getNativeShowcaseScene();
186 if (scene && scene !== requestedScene) {
187 requestedScene = scene;
188 // Draws belong to their scene request; revisiting review must wait for its new surface.
189 clearShowcaseRenderSignal();
190 agentActivity = cancelRun(agentActivity);
191 }
192 };
193
194 const seedPendingTasks = () => {
195 const pendingTaskList = buildShowcasePendingTasks(
196 appAtomRegistry.get(environmentProjects.projectsAtom),
197 Date.now(),
198 );
199 if (pendingTaskList.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return;
200 const run: Run = { cancelled: false, done: false };
201 pendingTasks = run;
202 for (const task of pendingTaskList) holdEditingQueuedMessage(task.messageId);
203 void Promise.all(
204 pendingTaskList.map((task) =>
205 retryShowcaseOperation(
206 async () => {
207 await enqueueThreadOutboxMessage(task);
208 return true;
209 },
210 { isCancelled: isCancelled(run) },
211 ),
212 ),
213 ).then((results) => {
214 if (run.cancelled || !results.every(Boolean)) return;
215 run.done = true;
216 evaluate();
217 });
218 };
219
220 const stageAgentActivity = () => {
221 const run: Run = { cancelled: false, done: false };
222 agentActivity = run;
223 let lastOutcome: string | null = null;
224 void retryShowcaseOperation(
225 async () => {
226 const now = Date.now();
227 const activity = buildShowcaseAgentActivity(
228 appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
229 appAtomRegistry.get(environmentProjects.projectsAtom),
230 now,
231 );
232 const outcome =
233 activity === null
234 ? "fixture threads not loaded"
235 : await stageShowcaseAgentActivity(activity, now);
236 if (outcome === true) return true;
237 if (outcome !== lastOutcome) {
238 console.warn(`[showcase] agent activity not staged: ${outcome}`);
239 }
240 lastOutcome = outcome;
241 return false;
242 },
243 { isCancelled: isCancelled(run), attemptTimeoutMs: 60_000 },
244 ).then((staged) => {
245 if (run.cancelled || !staged) return;
246 run.done = true;
247 evaluate();
248 });
249 };
250
251 const updateReadiness = (key: string | null, scene: ShowcaseScene | null) => {
252 if (readiness?.key === key) return;
253 readiness?.cancel();
254 readiness = null;
255 if (key === null || scene === null) {
256 setReadyScene(null);
257 return;
258 }
259 if (scene === "terminal") Keyboard.dismiss();
260 let frame: number | null = null;
261 // Two frames after the settle delay so the scene has been drawn, not just committed.
262 const settleTimer = setTimeout(() => {
263 frame = requestAnimationFrame(() => {
264 frame = requestAnimationFrame(() => {
265 frame = null;
266 markNativeShowcaseReady(scene);
267 setReadyScene(scene);
268 });
269 });
270 }, READY_SETTLE_MS);
271 readiness = {
272 key,
273 cancel: () => {
274 clearTimeout(settleTimer);
275 if (frame !== null) cancelAnimationFrame(frame);
276 },
277 };
278 };
279
280 const step = () => {
281 readNativeRequests();
282
283 const preferences = appAtomRegistry.get(mobilePreferencesAtom);
284 const storedPreferences = AsyncResult.isSuccess(preferences) ? preferences.value : null;
285 const themeIds = resolveMobileThemeIds(storedPreferences ?? {});
286 const themeApplied = isShowcaseThemeApplied({ launchRequestRead, requestedTheme, themeIds });
287 // The write patches preferences optimistically, so the step it triggers
288 // already sees the theme applied. A failed save rolls the patch back, and
289 // the next step writes again.
290 if (requestedTheme !== null && !themeApplied && storedPreferences) {
291 appAtomRegistry.set(updateMobilePreferencesAtom, createMobileThemePairPatch(requestedTheme));
292 }
293 const themeId = themeIds[resolveThemeAppearance(storedPreferences)];
294
295 const projects = appAtomRegistry.get(environmentProjects.projectsAtom);
296 const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
297 const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID);
298 const hasServerFixture =
299 appAtomRegistry.get(workspaceConnections.stateAtom).hasReadyEnvironment &&
300 appAtomRegistry.get(workspaceConnections.environmentsAtom).length >= 3 &&
301 projects.length >= 3 &&
302 showcaseThread !== undefined;
303 if (hasServerFixture && pendingTasks === null) seedPendingTasks();
304 const hasFixture = hasServerFixture && pendingTasks?.done === true;
305
306 const navigationReady = rootNavigationRef.isReady();
307 const rootState = navigationReady ? rootNavigationRef.getRootState() : undefined;
308 const scene = rootState
309 ? showcaseSceneOnScreen(requestedScene, pathnameFromState(rootState))
310 : null;
311
312 if (requestedScene !== null && hasFixture && showcaseThread && rootState) {
313 const navigation = { requestedScene, scene, thread: showcaseThread };
314 // Arriving forgets the target, so leaving the scene again navigates back.
315 if (scene === requestedScene) {
316 lastNavigation = null;
317 } else if (shouldNavigateToShowcaseScene(lastNavigation, navigation)) {
318 lastNavigation = navigation;
319 rootNavigationRef.dispatch({
320 ...showcaseNavigationAction(
321 requestedScene,
322 String(showcaseThread.environmentId),
323 rootState,
324 ),
325 target: rootState.key,
326 });
327 }
328 }
329
330 if (scene === "agent-activity" && hasFixture) {
331 if (agentActivity === null) stageAgentActivity();
332 } else if (agentActivity?.done === false) {
333 agentActivity = cancelRun(agentActivity);
334 }
335
336 const render = getShowcaseRenderSnapshot();
337 updateReadiness(
338 showcaseReadinessKey({
339 scene,
340 requestedScene,
341 hasFixture,
342 orientationSettled,
343 themeApplied,
344 agentActivityStaged: agentActivity?.done === true,
345 themeId,
346 renderSignal: render.signal,
347 renderReports: render.reports,
348 }),
349 scene,
350 );
351 };
352
353 // Steps write atoms they also observe; a nested change reruns the step once
354 // the current one finishes instead of interleaving with it.
355 let stepping = false;
356 let stepAgain = false;
357 function evaluate() {
358 if (disposed) return;
359 if (stepping) {
360 stepAgain = true;
361 return;
362 }
363 stepping = true;
364 try {
365 do {
366 stepAgain = false;
367 step();
368 } while (stepAgain);
369 } finally {
370 stepping = false;
371 }
372 }
373
374 const unsubscribers = [
375 appAtomRegistry.subscribe(mobilePreferencesAtom, evaluate),
376 appAtomRegistry.subscribe(environmentProjects.projectsAtom, evaluate),
377 appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, evaluate),
378 appAtomRegistry.subscribe(workspaceConnections.stateAtom, evaluate),
379 appAtomRegistry.subscribe(workspaceConnections.environmentsAtom, evaluate),
380 subscribeToShowcaseRenderSignal(evaluate),
381 rootNavigationRef.addListener("state", evaluate),
382 ];
383 const appearanceSubscription = Appearance.addChangeListener(evaluate);
384 const pollTimer = setInterval(evaluate, NATIVE_POLL_MS);
385 evaluate();
386
387 return () => {
388 disposed = true;
389 clearInterval(devMenuTimer);
390 clearInterval(pollTimer);
391 appearanceSubscription.remove();
392 for (const unsubscribe of unsubscribers) unsubscribe();
393 pendingTasks = cancelRun(pendingTasks);
394 agentActivity = cancelRun(agentActivity);
395 readiness?.cancel();
396 readiness = null;
397 setReadyScene(null);
398 };
399}
400
401/** The capture harness waits for this marker before taking a screenshot. */
402export function ShowcaseReadyMarker() {
403 const scene = useSyncExternalStore(subscribeToReadyScene, getReadyScene, getReadyScene);
404 if (scene === null) return null;
405 return (
406 <View
407 pointerEvents="none"
408 testID={`showcase-ready-${scene}`}
409 style={{ position: "absolute", width: 1, height: 1, opacity: 0.01 }}
410 />
411 );
412}