1import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2import { Keyboard, View } from "react-native";
3import {
4 CommonActions,
5 type NavigationState,
6 type PartialState,
7 StackActions,
8 useNavigation,
9} from "@react-navigation/native";
10import { AsyncResult } from "effect/reactivity";
11
12import { useConnectionController } from "../connection/useConnectionController";
13import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
14import type { MobileThemeId } from "../../lib/mobileTheme";
15import { useProjects, useThreadShells } from "../../state/entities";
16import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
17import { holdEditingQueuedMessage } from "../../state/use-thread-outbox";
18import { useWorkspaceState } from "../../state/workspace";
19import {
20 applyNativeShowcaseOrientation,
21 getNativeShowcaseOrientation,
22 getNativeShowcasePairingUrls,
23 getNativeShowcaseScene,
24 getNativeShowcaseTheme,
25 markNativeShowcaseReady,
26 type ShowcaseScene,
27} from "./nativeShowcaseScene";
28import {
29 buildShowcasePendingTasks,
30 SHOWCASE_PENDING_TASK_DEFINITIONS,
31} from "./showcasePendingTasks";
32import { buildShowcaseAgentActivity } from "./showcaseAgentActivity";
33import { retryShowcaseOperation } from "./showcaseRetry";
34import {
35 clearShowcaseRenderSignal,
36 getShowcaseRenderSignal,
37 isShowcaseNativeContentReady,
38 subscribeToShowcaseRenderSignal,
39} from "./showcaseRenderSignal";
40import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity";
41
42const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
43const SHOWCASE_THREAD_ID = "remote-command-center";
44
45type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number];
46
47function sceneFromPathname(pathname: string): ShowcaseScene | null {
48 const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
49 if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
50 return "environments";
51 }
52 if (routePath.endsWith("/terminal")) return "terminal";
53 if (routePath.endsWith("/review")) return "review";
54 if (routePath.startsWith("/threads/")) return "thread";
55 if (routePath === "/") return "threads";
56 return null;
57}
58
59// Showcase capture only runs in showcase builds. Gate the mount so normal builds
60// never subscribe to workspace, project, or thread shell state.
61export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
62 if (!SHOWCASE_ENABLED) return null;
63 return <EnabledShowcaseCaptureCoordinator pathname={props.pathname} />;
64}
65
66function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
67 const navigation = useNavigation();
68 const { connectPairingUrl } = useConnectionController();
69 const {
70 isReady: appearancePreferencesReady,
71 themeId,
72 themeIds,
73 setThemeIdForBothAppearances,
74 } = useAppearancePreferences();
75 const workspace = useWorkspaceState();
76 const projects = useProjects();
77 const threads = useThreadShells();
78 const attemptedPairingRef = useRef(new Set<string>());
79 const seededPendingTaskIdsRef = useRef(new Set<string>());
80 const [pairingUrls, setPairingUrls] = useState<ReadonlyArray<string>>([]);
81 const [pendingTasksReady, setPendingTasksReady] = useState(false);
82 const [requestedScene, setRequestedScene] = useState<ShowcaseScene | null>(null);
83 const [requestedTheme, setRequestedTheme] = useState<MobileThemeId | null>(null);
84 const [themeRequestSettled, setThemeRequestSettled] = useState(false);
85 const [readyScene, setReadyScene] = useState<ShowcaseScene | null>(null);
86 const [orientationSettled, setOrientationSettled] = useState(false);
87 const [agentActivityStaged, setAgentActivityStaged] = useState(false);
88 const requestedSceneRef = useRef<ShowcaseScene | null>(null);
89 // Staging reads the latest entities without restarting on every shell
90 // update, which would re-enter a permission prompt that is still open.
91 const entitiesRef = useRef({ threads, projects });
92 useEffect(() => {
93 entitiesRef.current = { threads, projects };
94 }, [projects, threads]);
95 const renderSignal = useSyncExternalStore(
96 subscribeToShowcaseRenderSignal,
97 getShowcaseRenderSignal,
98 getShowcaseRenderSignal,
99 );
100
101 useEffect(() => {
102 if (pairingUrls.length > 0) return;
103
104 const readLaunchRequest = () => {
105 const values = getNativeShowcasePairingUrls();
106 if (values.length === 0) return;
107 // The palette rides the same launch request as the pairing URLs, so
108 // reading it here settles it without a timeout that could expire while
109 // the request is still on its way.
110 setRequestedTheme(getNativeShowcaseTheme());
111 setThemeRequestSettled(true);
112 setPairingUrls(values);
113 };
114 readLaunchRequest();
115 const interval = setInterval(readLaunchRequest, 250);
116 return () => clearInterval(interval);
117 }, [pairingUrls.length]);
118
119 useEffect(() => {
120 if (orientationSettled) return;
121 const orientation = getNativeShowcaseOrientation();
122 if (orientation === null) {
123 setOrientationSettled(true);
124 return;
125 }
126
127 let cancelled = false;
128 let lastOutcome: string | null = null;
129 void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), {
130 isCancelled: () => cancelled,
131 }).then((applied) => {
132 if (!cancelled && applied) setOrientationSettled(true);
133 });
134 return () => {
135 cancelled = true;
136 };
137 }, [orientationSettled]);
138
139 useEffect(() => {
140 const readRequestedScene = () => {
141 const value = getNativeShowcaseScene();
142 if (!value || requestedSceneRef.current === value) return;
143 requestedSceneRef.current = value;
144 // A native draw belongs only to the scene request that produced it. In
145 // particular, revisiting review must wait for its newly mounted surface.
146 clearShowcaseRenderSignal();
147 setAgentActivityStaged(false);
148 setRequestedScene(value);
149 };
150 readRequestedScene();
151 const interval = setInterval(readRequestedScene, 250);
152 return () => clearInterval(interval);
153 }, []);
154
155 // Captures pick a palette for both color schemes so the requested theme is
156 // used whichever system appearance the runner set on the device.
157 const themeApplied =
158 requestedTheme === null
159 ? themeRequestSettled
160 : themeIds.light === requestedTheme && themeIds.dark === requestedTheme;
161
162 useEffect(() => {
163 if (
164 requestedTheme === null ||
165 themeApplied ||
166 // Writing before stored preferences load would be overwritten by them.
167 !appearancePreferencesReady
168 ) {
169 return;
170 }
171 setThemeIdForBothAppearances(requestedTheme);
172 }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]);
173
174 useEffect(() => {
175 if (pairingUrls.length === 0) return;
176 let cancelled = false;
177 void (async () => {
178 await Promise.all(
179 pairingUrls.map(async (pairingUrl) => {
180 if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return;
181 const paired = await retryShowcaseOperation(
182 async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)),
183 { isCancelled: () => cancelled },
184 );
185 if (paired) attemptedPairingRef.current.add(pairingUrl);
186 }),
187 );
188 })();
189 return () => {
190 cancelled = true;
191 };
192 }, [connectPairingUrl, pairingUrls]);
193
194 const routeScene = sceneFromPathname(props.pathname);
195 // Agent activity is captured over the thread list: the runner locks the
196 // simulator or opens the notification shade on top of it.
197 const scene =
198 requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene;
199 const hasServerFixture =
200 workspace.state.hasReadyEnvironment &&
201 workspace.environments.length >= 3 &&
202 projects.length >= 3 &&
203 threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID);
204 const hasFixture = hasServerFixture && pendingTasksReady;
205 const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID);
206
207 useEffect(() => {
208 if (!hasServerFixture || pendingTasksReady) return;
209
210 const pendingTasks = buildShowcasePendingTasks(projects, Date.now());
211 if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return;
212
213 let cancelled = false;
214 for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId);
215 void (async () => {
216 const results = await Promise.all(
217 pendingTasks.map(async (task) => {
218 const messageId = String(task.messageId);
219 if (seededPendingTaskIdsRef.current.has(messageId)) return true;
220 const seeded = await retryShowcaseOperation(
221 async () => {
222 await enqueueThreadOutboxMessage(task);
223 return true;
224 },
225 { isCancelled: () => cancelled },
226 );
227 if (seeded) seededPendingTaskIdsRef.current.add(messageId);
228 return seeded;
229 }),
230 );
231 if (!cancelled && results.every(Boolean)) setPendingTasksReady(true);
232 })();
233 return () => {
234 cancelled = true;
235 };
236 }, [hasServerFixture, pendingTasksReady, projects]);
237
238 useEffect(() => {
239 if (requestedScene === null || !hasFixture || !showcaseThread) return;
240 if (scene === requestedScene) return;
241
242 const params = {
243 environmentId: String(showcaseThread.environmentId),
244 threadId: SHOWCASE_THREAD_ID,
245 };
246 if (requestedScene === "threads") {
247 navigation.dispatch(StackActions.popToTop());
248 return;
249 }
250 // Follows the environments scene, whose settings sheet popToTop leaves open.
251 if (requestedScene === "agent-activity") {
252 navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] }));
253 return;
254 }
255 const routes: ShowcaseResetRoute[] = [{ name: "Home" }];
256 if (requestedScene === "environments") {
257 routes.push({
258 name: "SettingsSheet",
259 state: {
260 index: 0,
261 routes: [
262 {
263 name: "SettingsContent",
264 state: {
265 index: 1,
266 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],
267 },
268 },
269 ],
270 },
271 });
272 } else {
273 routes.push({ name: "Thread", params });
274 if (requestedScene === "terminal") {
275 routes.push({
276 name: "ThreadTerminal",
277 params: { ...params, terminalId: "term-1" },
278 });
279 } else if (requestedScene === "review") {
280 routes.push({ name: "ThreadReview", params });
281 }
282 }
283 navigation.dispatch(
284 CommonActions.reset({
285 index: routes.length - 1,
286 routes,
287 }),
288 );
289 }, [hasFixture, navigation, requestedScene, scene, showcaseThread]);
290
291 useEffect(() => {
292 if (scene !== "agent-activity" || !hasFixture || agentActivityStaged) {
293 return;
294 }
295 let cancelled = false;
296 let lastOutcome: string | null = null;
297 void retryShowcaseOperation(
298 async () => {
299 const now = Date.now();
300 const { threads: latestThreads, projects: latestProjects } = entitiesRef.current;
301 const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now);
302 const outcome =
303 activity === null
304 ? "fixture threads not loaded"
305 : await stageShowcaseAgentActivity(activity, now);
306 if (outcome === true) return true;
307 // Surfaces in the runner's Metro output when the scene never turns ready.
308 if (outcome !== lastOutcome)
309 console.warn(`[showcase] agent activity not staged: ${outcome}`);
310 lastOutcome = outcome;
311 return false;
312 },
313 // The first attempt waits on the notification permission prompt until
314 // the runner answers it.
315 { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 },
316 ).then((staged) => {
317 if (!cancelled && staged) setAgentActivityStaged(true);
318 });
319 return () => {
320 cancelled = true;
321 };
322 }, [agentActivityStaged, hasFixture, scene]);
323
324 useEffect(() => {
325 if (
326 scene === null ||
327 requestedScene === null ||
328 scene !== requestedScene ||
329 !hasFixture ||
330 // Never report a scene ready while the capture orientation is still
331 // being applied — a screenshot taken early has the wrong dimensions.
332 !orientationSettled ||
333 // Likewise for the palette: an early screenshot shows the default theme.
334 !themeApplied ||
335 (scene === "agent-activity" && !agentActivityStaged) ||
336 !isShowcaseNativeContentReady({ scene, themeId, renderSignal })
337 ) {
338 setReadyScene(null);
339 return;
340 }
341 if (scene === "terminal") Keyboard.dismiss();
342
343 let renderFrame: number | null = null;
344 let readyFrame: number | null = null;
345 const settleTimer = setTimeout(() => {
346 renderFrame = requestAnimationFrame(() => {
347 readyFrame = requestAnimationFrame(() => {
348 markNativeShowcaseReady(scene);
349 setReadyScene(scene);
350 });
351 });
352 }, 500);
353 return () => {
354 clearTimeout(settleTimer);
355 if (renderFrame !== null) cancelAnimationFrame(renderFrame);
356 if (readyFrame !== null) cancelAnimationFrame(readyFrame);
357 };
358 }, [
359 agentActivityStaged,
360 hasFixture,
361 orientationSettled,
362 renderSignal,
363 requestedScene,
364 scene,
365 themeApplied,
366 themeId,
367 ]);
368
369 if (readyScene === null) return null;
370
371 return (
372 <View
373 pointerEvents="none"
374 testID={`showcase-ready-${readyScene}`}
375 style={{ position: "absolute", width: 1, height: 1, opacity: 0.01 }}
376 />
377 );
378}