| 1 | import { useEffect, useRef, useState, useSyncExternalStore } from "react"; |
| 2 | import { Keyboard, View } from "react-native"; |
| 3 | import { |
| 4 | CommonActions, |
| 5 | type NavigationState, |
| 6 | type PartialState, |
| 7 | StackActions, |
| 8 | useNavigation, |
| 9 | } from "@react-navigation/native"; |
| 10 | import { AsyncResult } from "effect/reactivity"; |
| 11 | |
| 12 | import { useConnectionController } from "../connection/useConnectionController"; |
| 13 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 14 | import type { MobileThemeId } from "../../lib/mobileTheme"; |
| 15 | import { useProjects, useThreadShells } from "../../state/entities"; |
| 16 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; |
| 17 | import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; |
| 18 | import { useWorkspaceState } from "../../state/workspace"; |
| 19 | import { |
| 20 | applyNativeShowcaseOrientation, |
| 21 | getNativeShowcaseOrientation, |
| 22 | getNativeShowcasePairingUrls, |
| 23 | getNativeShowcaseScene, |
| 24 | getNativeShowcaseTheme, |
| 25 | markNativeShowcaseReady, |
| 26 | type ShowcaseScene, |
| 27 | } from "./nativeShowcaseScene"; |
| 28 | import { |
| 29 | buildShowcasePendingTasks, |
| 30 | SHOWCASE_PENDING_TASK_DEFINITIONS, |
| 31 | } from "./showcasePendingTasks"; |
| 32 | import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; |
| 33 | import { retryShowcaseOperation } from "./showcaseRetry"; |
| 34 | import { |
| 35 | clearShowcaseRenderSignal, |
| 36 | getShowcaseRenderSignal, |
| 37 | isShowcaseNativeContentReady, |
| 38 | subscribeToShowcaseRenderSignal, |
| 39 | } from "./showcaseRenderSignal"; |
| 40 | import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; |
| 41 | |
| 42 | const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; |
| 43 | const SHOWCASE_THREAD_ID = "remote-command-center"; |
| 44 | |
| 45 | type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number]; |
| 46 | |
| 47 | function 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. |
| 61 | export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { |
| 62 | if (!SHOWCASE_ENABLED) return null; |
| 63 | return <EnabledShowcaseCaptureCoordinator pathname={props.pathname} />; |
| 64 | } |
| 65 | |
| 66 | function 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 | } |