From 749f3dd7964af767dbfe4a3371f96e887b7968f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:18:04 -0700 Subject: [PATCH 1/2] refactor(mobile): showcase capture runs as an app service, not a chain of effects The showcase capture coordinator was a component inside RootStackLayout with ten useEffects and nine pieces of state passing values between them (launch request -> pairing, orientation, scene polling, theme write, fixture seeding, navigation, agent-activity staging, readiness settle). Each step waited for a render to learn what the previous one did. installShowcaseCapture now runs once at app start (next to the other app services in App.tsx). It subscribes to the atoms it reads, the navigation container, the render signal and Appearance, and runs one step function that reads every input from its store and feeds them to the merged showcaseCaptureModel. RootStackLayout keeps only ShowcaseReadyMarker, which reads the ready scene from a small external store. Effects: ShowcaseCaptureCoordinator.tsx 10 -> 0. Production builds are unaffected (EXPO_PUBLIC_SHOWCASE gates everything). Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 4 +- apps/mobile/src/Stack.tsx | 12 +- .../AppearancePreferencesProvider.tsx | 2 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 572 ++++++++++-------- 4 files changed, 343 insertions(+), 247 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index f63b210a11cf8..f5c15ee884ce8 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,7 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; +import { installShowcaseCapture } from "./features/showcase/ShowcaseCaptureCoordinator"; import { presentPendingIncomingShare, pruneIncomingShareImports, @@ -42,7 +43,7 @@ import { installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; -import { RootStack, workspaceLocationFromState } from "./Stack"; +import { RootStack, rootPathnameFromState, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells } from "./state/threads"; @@ -178,6 +179,7 @@ const appServiceDisposers = [ installAgentNotificationNavigation(linkToAppPath), installAppShortcuts(linkToAppPath), installAppUpdateChecks(), + installShowcaseCapture(rootPathnameFromState), ]; disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { for (const dispose of appServiceDisposers) dispose(); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index a5d810ed7a91d..7a0042903a3c3 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -137,7 +137,7 @@ import { isWorkspaceOverlayRoute, registerPresentedRoutes, } from "./features/layout/workspace-overlay-routes"; -import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; +import { ShowcaseReadyMarker } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, @@ -492,12 +492,16 @@ export function workspaceLocationFromState(state: NavigationState) { }; } +export function rootPathnameFromState(state: NavigationState): string { + const path = getPathFromState(state, navigationPathConfig); + return path.startsWith("/") ? path : `/${path}`; +} + function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { - const path = getPathFromState(props.state, navigationPathConfig); - const pathname = path.startsWith("/") ? path : `/${path}`; + const pathname = rootPathnameFromState(props.state); const workspaceLocation = workspaceLocationFromState(props.state); const settingsOpen = settingsOpenIn( props.state.routes @@ -524,7 +528,7 @@ function RootStackLayout(props: { routeDraftId={routeDraftId} > - + diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index bdde057dd232f..b01529ddd4845 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -111,7 +111,7 @@ function resolveThemeVariablesByAppearance( } /** The appearance whose theme is on screen, read from the native scheme. */ -function resolveThemeAppearance(stored: Preferences | null): MobileThemeAppearance { +export function resolveThemeAppearance(stored: Preferences | null): MobileThemeAppearance { const themeMode = normalizeMobileThemeMode(stored?.themeMode); if (themeMode !== "system") return themeMode; return Appearance.getColorScheme() === "dark" ? "dark" : "light"; diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 5cb3f5ad14791..f1dbb7aaff663 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,16 +1,30 @@ -import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react"; -import { Keyboard, View } from "react-native"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { useSyncExternalStore } from "react"; +import { Appearance, Keyboard, View } from "react-native"; +import { + CommonActions, + StackActions, + type NavigationAction, + type NavigationState, +} from "@react-navigation/native"; +import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { requireOptionalNativeModule } from "expo"; -import { useConnectionController } from "../connection/useConnectionController"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; -import type { MobileThemeId } from "../../lib/mobileTheme"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { connectPairingUrl } from "../../connection/onboarding"; +import { + createMobileThemePairPatch, + resolveMobileThemeIds, + type MobileThemeId, +} from "../../lib/mobileTheme"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; +import { environmentProjects } from "../../state/projects"; import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; +import { environmentThreadShells } from "../../state/threads"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; -import { useWorkspaceState } from "../../state/workspace"; +import { workspaceConnections } from "../../state/workspace"; +import { resolveThemeAppearance } from "../settings/appearance/AppearancePreferencesProvider"; import { applyNativeShowcaseOrientation, getNativeShowcaseOrientation, @@ -20,10 +34,6 @@ import { markNativeShowcaseReady, type ShowcaseScene, } from "./nativeShowcaseScene"; -import { - buildShowcasePendingTasks, - SHOWCASE_PENDING_TASK_DEFINITIONS, -} from "./showcasePendingTasks"; import { buildShowcaseAgentActivity } from "./showcaseAgentActivity"; import { isShowcaseThemeApplied, @@ -31,288 +41,368 @@ import { showcaseReadinessKey, showcaseSceneOnScreen, } from "./showcaseCaptureModel"; -import { retryShowcaseOperation } from "./showcaseRetry"; +import { + buildShowcasePendingTasks, + SHOWCASE_PENDING_TASK_DEFINITIONS, +} from "./showcasePendingTasks"; import { clearShowcaseRenderSignal, getShowcaseRenderSnapshot, subscribeToShowcaseRenderSignal, } from "./showcaseRenderSignal"; +import { retryShowcaseOperation } from "./showcaseRetry"; import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity"; -import { openSettings } from "../settings/settings-navigation"; +import { settingsNavigationState } from "../settings/settings-navigation"; import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; +// Native launch requests (pairing links, scene switches) have no change event. +const NATIVE_POLL_MS = 250; +const READY_SETTLE_MS = 500; -export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) { - if (!SHOWCASE_ENABLED) return null; - return ; -} - -function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string }) { - const navigation = useNavigation(); - useEffect(() => { - const menu = requireOptionalNativeModule<{ - closeMenu(): void; - setToolsButtonVisible(visible: boolean): void; - }>("ExpoDevMenu"); - // The dev menu can open after bundle loading and block orientation requests. - const timer = setInterval(() => { - menu?.closeMenu(); - menu?.setToolsButtonVisible(false); - }, 1_000); - return () => clearInterval(timer); - }, []); - const { connectPairingUrl } = useConnectionController(); - const { - isReady: appearancePreferencesReady, - themeId, - themeIds, - setThemeIdForBothAppearances, - } = useAppearancePreferences(); - const workspace = useWorkspaceState(); - const projects = useProjects(); - const threads = useThreadShells(); - const attemptedPairingRef = useRef(new Set()); - const seededPendingTaskIdsRef = useRef(new Set()); - const [pairingUrls, setPairingUrls] = useState>([]); - const [pendingTasksReady, setPendingTasksReady] = useState(false); - const [requestedScene, setRequestedScene] = useState(null); - const [requestedTheme, setRequestedTheme] = useState(null); - const [themeRequestSettled, setThemeRequestSettled] = useState(false); - const [readyScene, setReadyScene] = useState(null); - const [orientationSettled, setOrientationSettled] = useState(false); - const [agentActivityStaged, setAgentActivityStaged] = useState(false); - const requestedSceneRef = useRef(null); - // Read current entities without restarting on shell updates and re-entering open permission prompts. - const readFixtureEntities = useEffectEvent(() => ({ threads, projects })); - const { signal: renderSignal, reports: renderReports } = useSyncExternalStore( - subscribeToShowcaseRenderSignal, - getShowcaseRenderSnapshot, - getShowcaseRenderSnapshot, - ); - - useEffect(() => { - if (pairingUrls.length > 0) return; +const readySceneListeners = new Set<() => void>(); +let readyScene: ShowcaseScene | null = null; - const readLaunchRequest = () => { - const values = getNativeShowcasePairingUrls(); - if (values.length === 0) return; - setRequestedTheme(getNativeShowcaseTheme()); - setThemeRequestSettled(true); - setPairingUrls(values); - }; - readLaunchRequest(); - const interval = setInterval(readLaunchRequest, 250); - return () => clearInterval(interval); - }, [pairingUrls.length]); +function setReadyScene(scene: ShowcaseScene | null): void { + if (readyScene === scene) return; + readyScene = scene; + for (const listener of readySceneListeners) listener(); +} - useEffect(() => { - if (orientationSettled) return; - const orientation = getNativeShowcaseOrientation(); - if (orientation === null) { - // oxlint-disable-next-line react/set-state-in-effect -- Capture readiness follows the native orientation change. - setOrientationSettled(true); - return; - } +function subscribeToReadyScene(listener: () => void): () => void { + readySceneListeners.add(listener); + return () => readySceneListeners.delete(listener); +} - let cancelled = false; - void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { - isCancelled: () => cancelled, - }).then((applied) => { - if (!cancelled && applied) setOrientationSettled(true); - }); - return () => { - cancelled = true; - }; - }, [orientationSettled]); +function getReadyScene(): ShowcaseScene | null { + return readyScene; +} - useEffect(() => { - const readRequestedScene = () => { - const value = getNativeShowcaseScene(); - if (!value || requestedSceneRef.current === value) return; - requestedSceneRef.current = value; - // Draws belong to their scene request; revisiting review must wait for its new surface. - clearShowcaseRenderSignal(); - setAgentActivityStaged(false); - setRequestedScene(value); - }; - readRequestedScene(); - const interval = setInterval(readRequestedScene, 250); - return () => clearInterval(interval); - }, []); +interface Run { + cancelled: boolean; + done: boolean; +} - const themeApplied = isShowcaseThemeApplied({ - launchRequestRead: themeRequestSettled, - requestedTheme, - themeIds, - }); +function cancelRun(run: Run | null): null { + if (run) run.cancelled = true; + return null; +} - useEffect(() => { - if (requestedTheme === null || themeApplied || !appearancePreferencesReady) { - return; - } - setThemeIdForBothAppearances(requestedTheme); - }, [appearancePreferencesReady, requestedTheme, setThemeIdForBothAppearances, themeApplied]); +function showcaseNavigationAction( + scene: ShowcaseScene, + environmentId: string, + rootState: NavigationState, +): NavigationAction { + const params = { environmentId, threadId: SHOWCASE_THREAD_ID }; + switch (scene) { + case "threads": + return StackActions.popToTop(); + case "agent-activity": + return CommonActions.reset({ index: 0, routes: [{ name: "Home" }] }); + case "environments": + return CommonActions.reset(settingsNavigationState(rootState, "SettingsEnvironments")); + case "terminal": + return CommonActions.navigate("ThreadTerminal", { ...params, terminalId: "term-1" }); + case "review": + return CommonActions.navigate("ThreadReview", params); + case "thread": + return selectWorkspaceDetail({ name: "Thread", params }); + } +} - useEffect(() => { - if (pairingUrls.length === 0) return; - let cancelled = false; - void (async () => { - await Promise.all( - pairingUrls.map(async (pairingUrl) => { - if (cancelled || attemptedPairingRef.current.has(pairingUrl)) return; - const paired = await retryShowcaseOperation( - async () => AsyncResult.isSuccess(await connectPairingUrl(pairingUrl)), - { isCancelled: () => cancelled }, - ); - if (paired) attemptedPairingRef.current.add(pairingUrl); - }), - ); - })(); - return () => { - cancelled = true; - }; - }, [connectPairingUrl, pairingUrls]); +/** + * Drives a showcase capture: pairs servers, seeds the fixture, applies theme and + * orientation, and navigates to each scene the native harness requests. Inputs + * are read from their stores on each step, so nothing depends on render timing. + */ +export function installShowcaseCapture( + pathnameFromState: (state: NavigationState) => string, +): () => void { + if (!SHOWCASE_ENABLED) return () => {}; - const scene = showcaseSceneOnScreen(requestedScene, props.pathname); - const hasServerFixture = - workspace.state.hasReadyEnvironment && - workspace.environments.length >= 3 && - projects.length >= 3 && - threads.some((thread) => String(thread.id) === SHOWCASE_THREAD_ID); - const hasFixture = hasServerFixture && pendingTasksReady; - const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + let disposed = false; + let launchRequestRead = false; + let requestedTheme: MobileThemeId | null = null; + let orientationSettled = false; + let requestedScene: ShowcaseScene | null = null; + let pendingTasks: Run | null = null; + let agentActivity: Run | null = null; + let lastNavigation: string | null = null; + let readiness: { key: string; cancel: () => void } | null = null; + const isCancelled = (run?: Run) => () => disposed || run?.cancelled === true; - useEffect(() => { - if (!hasServerFixture || pendingTasksReady) return; + const devMenu = requireOptionalNativeModule<{ + closeMenu(): void; + setToolsButtonVisible(visible: boolean): void; + }>("ExpoDevMenu"); + // The dev menu can open after bundle loading and block orientation requests. + const devMenuTimer = setInterval(() => { + devMenu?.closeMenu(); + devMenu?.setToolsButtonVisible(false); + }, 1_000); - const pendingTasks = buildShowcasePendingTasks(projects, Date.now()); - if (pendingTasks.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + const orientation = getNativeShowcaseOrientation(); + if (orientation === null) { + orientationSettled = true; + } else { + void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { + isCancelled: isCancelled(), + }).then((applied) => { + if (!applied) return; + orientationSettled = true; + evaluate(); + }); + } - let cancelled = false; - for (const task of pendingTasks) holdEditingQueuedMessage(task.messageId); - void (async () => { - const results = await Promise.all( - pendingTasks.map(async (task) => { - const messageId = String(task.messageId); - if (seededPendingTaskIdsRef.current.has(messageId)) return true; - const seeded = await retryShowcaseOperation( - async () => { - await enqueueThreadOutboxMessage(task); - return true; - }, - { isCancelled: () => cancelled }, - ); - if (seeded) seededPendingTaskIdsRef.current.add(messageId); - return seeded; - }), + const pair = (pairingUrls: ReadonlyArray) => { + for (const pairingUrl of pairingUrls) { + void retryShowcaseOperation( + async () => + AsyncResult.isSuccess( + await runAtomCommand( + appAtomRegistry, + connectPairingUrl, + { pairingUrl }, + { reportFailure: false }, + ), + ), + { isCancelled: isCancelled() }, ); - if (!cancelled && results.every(Boolean)) setPendingTasksReady(true); - })(); - return () => { - cancelled = true; - }; - }, [hasServerFixture, pendingTasksReady, projects]); - - useEffect(() => { - if (requestedScene === null || !hasFixture || !showcaseThread) return; - if (scene === requestedScene) return; - - const params = { - environmentId: String(showcaseThread.environmentId), - threadId: SHOWCASE_THREAD_ID, - }; - if (requestedScene === "threads") { - navigation.dispatch(StackActions.popToTop()); - return; } - if (requestedScene === "agent-activity") { - navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Home" }] })); - return; + }; + + const readNativeRequests = () => { + if (!launchRequestRead) { + const pairingUrls = getNativeShowcasePairingUrls(); + if (pairingUrls.length > 0) { + launchRequestRead = true; + requestedTheme = getNativeShowcaseTheme(); + pair(pairingUrls); + } } - if (requestedScene === "environments") { - openSettings(navigation, "SettingsEnvironments"); - } else if (requestedScene === "terminal") { - navigation.dispatch( - CommonActions.navigate("ThreadTerminal", { ...params, terminalId: "term-1" }), - ); - } else if (requestedScene === "review") { - navigation.dispatch(CommonActions.navigate("ThreadReview", params)); - } else { - navigation.dispatch(selectWorkspaceDetail({ name: "Thread", params })); + const scene = getNativeShowcaseScene(); + if (scene && scene !== requestedScene) { + requestedScene = scene; + // Draws belong to their scene request; revisiting review must wait for its new surface. + clearShowcaseRenderSignal(); + agentActivity = cancelRun(agentActivity); } - }, [hasFixture, navigation, requestedScene, scene, showcaseThread]); + }; - useEffect(() => { - if (scene !== "agent-activity" || !hasFixture || agentActivityStaged) { - return; - } - let cancelled = false; + const seedPendingTasks = () => { + const pendingTaskList = buildShowcasePendingTasks( + appAtomRegistry.get(environmentProjects.projectsAtom), + Date.now(), + ); + if (pendingTaskList.length !== SHOWCASE_PENDING_TASK_DEFINITIONS.length) return; + const run: Run = { cancelled: false, done: false }; + pendingTasks = run; + for (const task of pendingTaskList) holdEditingQueuedMessage(task.messageId); + void Promise.all( + pendingTaskList.map((task) => + retryShowcaseOperation( + async () => { + await enqueueThreadOutboxMessage(task); + return true; + }, + { isCancelled: isCancelled(run) }, + ), + ), + ).then((results) => { + if (run.cancelled || !results.every(Boolean)) return; + run.done = true; + evaluate(); + }); + }; + + const stageAgentActivity = () => { + const run: Run = { cancelled: false, done: false }; + agentActivity = run; let lastOutcome: string | null = null; void retryShowcaseOperation( async () => { const now = Date.now(); - const { threads: latestThreads, projects: latestProjects } = readFixtureEntities(); - const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now); + const activity = buildShowcaseAgentActivity( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + appAtomRegistry.get(environmentProjects.projectsAtom), + now, + ); const outcome = activity === null ? "fixture threads not loaded" : await stageShowcaseAgentActivity(activity, now); if (outcome === true) return true; - if (outcome !== lastOutcome) + if (outcome !== lastOutcome) { console.warn(`[showcase] agent activity not staged: ${outcome}`); + } lastOutcome = outcome; return false; }, - { isCancelled: () => cancelled, attemptTimeoutMs: 60_000 }, + { isCancelled: isCancelled(run), attemptTimeoutMs: 60_000 }, ).then((staged) => { - if (!cancelled && staged) setAgentActivityStaged(true); + if (run.cancelled || !staged) return; + run.done = true; + evaluate(); }); - return () => { - cancelled = true; - }; - }, [agentActivityStaged, hasFixture, scene]); + }; - const readinessKey = showcaseReadinessKey({ - scene, - requestedScene, - hasFixture, - orientationSettled, - themeApplied, - agentActivityStaged, - themeId, - renderSignal, - renderReports, - }); - // Losing readiness hides the marker. A redraw or theme change while ready keeps it and re-settles. - if (readinessKey === null && readyScene !== null) setReadyScene(null); - - useEffect(() => { - if (readinessKey === null || scene === null) return; + const updateReadiness = (key: string | null, scene: ShowcaseScene | null) => { + if (readiness?.key === key) return; + readiness?.cancel(); + readiness = null; + if (key === null || scene === null) { + setReadyScene(null); + return; + } if (scene === "terminal") Keyboard.dismiss(); - - let renderFrame: number | null = null; - let readyFrame: number | null = null; + let frame: number | null = null; + // Two frames after the settle delay so the scene has been drawn, not just committed. const settleTimer = setTimeout(() => { - renderFrame = requestAnimationFrame(() => { - readyFrame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + frame = null; markNativeShowcaseReady(scene); setReadyScene(scene); }); }); - }, 500); - return () => { - clearTimeout(settleTimer); - if (renderFrame !== null) cancelAnimationFrame(renderFrame); - if (readyFrame !== null) cancelAnimationFrame(readyFrame); + }, READY_SETTLE_MS); + readiness = { + key, + cancel: () => { + clearTimeout(settleTimer); + if (frame !== null) cancelAnimationFrame(frame); + }, }; - }, [readinessKey, scene]); + }; + + const step = () => { + readNativeRequests(); + + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + const storedPreferences = AsyncResult.isSuccess(preferences) ? preferences.value : null; + const themeIds = resolveMobileThemeIds(storedPreferences ?? {}); + const themeApplied = isShowcaseThemeApplied({ launchRequestRead, requestedTheme, themeIds }); + // The write patches preferences optimistically, so the step it triggers + // already sees the theme applied. A failed save rolls the patch back, and + // the next step writes again. + if (requestedTheme !== null && !themeApplied && storedPreferences) { + appAtomRegistry.set(updateMobilePreferencesAtom, createMobileThemePairPatch(requestedTheme)); + } + const themeId = themeIds[resolveThemeAppearance(storedPreferences)]; + + const projects = appAtomRegistry.get(environmentProjects.projectsAtom); + const threads = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); + const showcaseThread = threads.find((thread) => String(thread.id) === SHOWCASE_THREAD_ID); + const hasServerFixture = + appAtomRegistry.get(workspaceConnections.stateAtom).hasReadyEnvironment && + appAtomRegistry.get(workspaceConnections.environmentsAtom).length >= 3 && + projects.length >= 3 && + showcaseThread !== undefined; + if (hasServerFixture && pendingTasks === null) seedPendingTasks(); + const hasFixture = hasServerFixture && pendingTasks?.done === true; + + const navigationReady = rootNavigationRef.isReady(); + const rootState = navigationReady ? rootNavigationRef.getRootState() : undefined; + const scene = rootState + ? showcaseSceneOnScreen(requestedScene, pathnameFromState(rootState)) + : null; + + if (requestedScene !== null && hasFixture && showcaseThread && rootState) { + const navigation = `${requestedScene}|${scene}`; + if (scene === requestedScene) { + lastNavigation = null; + } else if (navigation !== lastNavigation) { + lastNavigation = navigation; + rootNavigationRef.dispatch({ + ...showcaseNavigationAction( + requestedScene, + String(showcaseThread.environmentId), + rootState, + ), + target: rootState.key, + }); + } + } + + if (scene === "agent-activity" && hasFixture) { + if (agentActivity === null) stageAgentActivity(); + } else if (agentActivity?.done === false) { + agentActivity = cancelRun(agentActivity); + } + + const render = getShowcaseRenderSnapshot(); + updateReadiness( + showcaseReadinessKey({ + scene, + requestedScene, + hasFixture, + orientationSettled, + themeApplied, + agentActivityStaged: agentActivity?.done === true, + themeId, + renderSignal: render.signal, + renderReports: render.reports, + }), + scene, + ); + }; + + // Steps write atoms they also observe; a nested change reruns the step once + // the current one finishes instead of interleaving with it. + let stepping = false; + let stepAgain = false; + function evaluate() { + if (disposed) return; + if (stepping) { + stepAgain = true; + return; + } + stepping = true; + try { + do { + stepAgain = false; + step(); + } while (stepAgain); + } finally { + stepping = false; + } + } + + const unsubscribers = [ + appAtomRegistry.subscribe(mobilePreferencesAtom, evaluate), + appAtomRegistry.subscribe(environmentProjects.projectsAtom, evaluate), + appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, evaluate), + appAtomRegistry.subscribe(workspaceConnections.stateAtom, evaluate), + appAtomRegistry.subscribe(workspaceConnections.environmentsAtom, evaluate), + subscribeToShowcaseRenderSignal(evaluate), + rootNavigationRef.addListener("state", evaluate), + ]; + const appearanceSubscription = Appearance.addChangeListener(evaluate); + const pollTimer = setInterval(evaluate, NATIVE_POLL_MS); + evaluate(); + + return () => { + disposed = true; + clearInterval(devMenuTimer); + clearInterval(pollTimer); + appearanceSubscription.remove(); + for (const unsubscribe of unsubscribers) unsubscribe(); + pendingTasks = cancelRun(pendingTasks); + agentActivity = cancelRun(agentActivity); + readiness?.cancel(); + readiness = null; + setReadyScene(null); + }; +} - if (readyScene === null) return null; +/** The capture harness waits for this marker before taking a screenshot. */ +export function ShowcaseReadyMarker() { + const scene = useSyncExternalStore(subscribeToReadyScene, getReadyScene, getReadyScene); + if (scene === null) return null; return ( ); From 567a933770c04ff094fa59c7e14e79cb6a9de5b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:04:43 -0700 Subject: [PATCH 2/2] fix(mobile): showcase navigation retries when the thread shell changes The coordinator dispatched each scene's navigation once per (requested, on-screen) scene pair, so a dispatch that did not move the route stalled the capture. On main the navigation effect re-ran whenever the showcase thread shell changed; the dedupe now includes the shell, matching that. Co-Authored-By: Claude Opus 5.5 --- .../showcase/ShowcaseCaptureCoordinator.tsx | 9 ++++--- .../showcase/showcaseCaptureModel.test.ts | 13 ++++++++++ .../features/showcase/showcaseCaptureModel.ts | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index f1dbb7aaff663..f59f53fc591ee 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -40,6 +40,8 @@ import { SHOWCASE_THREAD_ID, showcaseReadinessKey, showcaseSceneOnScreen, + shouldNavigateToShowcaseScene, + type ShowcaseNavigationTarget, } from "./showcaseCaptureModel"; import { buildShowcasePendingTasks, @@ -127,7 +129,7 @@ export function installShowcaseCapture( let requestedScene: ShowcaseScene | null = null; let pendingTasks: Run | null = null; let agentActivity: Run | null = null; - let lastNavigation: string | null = null; + let lastNavigation: ShowcaseNavigationTarget | null = null; let readiness: { key: string; cancel: () => void } | null = null; const isCancelled = (run?: Run) => () => disposed || run?.cancelled === true; @@ -308,10 +310,11 @@ export function installShowcaseCapture( : null; if (requestedScene !== null && hasFixture && showcaseThread && rootState) { - const navigation = `${requestedScene}|${scene}`; + const navigation = { requestedScene, scene, thread: showcaseThread }; + // Arriving forgets the target, so leaving the scene again navigates back. if (scene === requestedScene) { lastNavigation = null; - } else if (navigation !== lastNavigation) { + } else if (shouldNavigateToShowcaseScene(lastNavigation, navigation)) { lastNavigation = navigation; rootNavigationRef.dispatch({ ...showcaseNavigationAction( diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts index eddf9bea4053f..729dbcabe4292 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts @@ -4,6 +4,7 @@ import { isShowcaseThemeApplied, showcaseReadinessKey, showcaseSceneOnScreen, + shouldNavigateToShowcaseScene, type ShowcaseReadinessInput, } from "./showcaseCaptureModel"; @@ -84,3 +85,15 @@ describe("showcaseReadinessKey", () => { expect(showcaseReadinessKey({ ...drawn, renderReports: 2 })).not.toBe(firstDraw); }); }); + +describe("shouldNavigateToShowcaseScene", () => { + it("dispatches again when the thread shell changes while the scene is still not on screen", () => { + const shell = { id: "remote-command-center" }; + const first = { requestedScene: "review", scene: "threads", thread: shell } as const; + expect(shouldNavigateToShowcaseScene(null, first)).toBe(true); + expect(shouldNavigateToShowcaseScene(first, { ...first })).toBe(false); + expect(shouldNavigateToShowcaseScene(first, { ...first, thread: { ...shell } })).toBe(true); + expect(shouldNavigateToShowcaseScene(first, { ...first, scene: "thread" })).toBe(true); + expect(shouldNavigateToShowcaseScene(first, { ...first, scene: "review" })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts index b790f51efa671..c47c58621c5ad 100644 --- a/apps/mobile/src/features/showcase/showcaseCaptureModel.ts +++ b/apps/mobile/src/features/showcase/showcaseCaptureModel.ts @@ -27,6 +27,31 @@ export function showcaseSceneOnScreen( : routeScene; } +export interface ShowcaseNavigationTarget { + readonly requestedScene: ShowcaseScene; + readonly scene: ShowcaseScene | null; + /** The showcase thread shell; a new shell (or environment) is a new target to navigate to. */ + readonly thread: object; +} + +/** + * Whether to dispatch the navigation for `next`, given the target last dispatched. A target is + * dispatched once, and again whenever any part of it changes, so a dispatch that did not move + * the route is retried when the thread shell or route updates. + */ +export function shouldNavigateToShowcaseScene( + last: ShowcaseNavigationTarget | null, + next: ShowcaseNavigationTarget, +): boolean { + return ( + next.scene !== next.requestedScene && + (last === null || + last.requestedScene !== next.requestedScene || + last.scene !== next.scene || + last.thread !== next.thread) + ); +} + export function isShowcaseThemeApplied(input: { readonly launchRequestRead: boolean; readonly requestedTheme: MobileThemeId | null;