Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 35 additions & 56 deletions apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore } from "react";
import { Keyboard, View } from "react-native";
import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";
import { AsyncResult } from "effect/reactivity";
Expand All @@ -25,31 +25,23 @@ import {
SHOWCASE_PENDING_TASK_DEFINITIONS,
} from "./showcasePendingTasks";
import { buildShowcaseAgentActivity } from "./showcaseAgentActivity";
import {
isShowcaseThemeApplied,
SHOWCASE_THREAD_ID,
showcaseReadinessKey,
showcaseSceneOnScreen,
} from "./showcaseCaptureModel";
import { retryShowcaseOperation } from "./showcaseRetry";
import {
clearShowcaseRenderSignal,
getShowcaseRenderSignal,
isShowcaseNativeContentReady,
getShowcaseRenderSnapshot,
subscribeToShowcaseRenderSignal,
} from "./showcaseRenderSignal";
import { stageShowcaseAgentActivity } from "./stageShowcaseAgentActivity";
import { openSettings } from "../settings/settings-navigation";
import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection";

const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
const SHOWCASE_THREAD_ID = "remote-command-center";

function sceneFromPathname(pathname: string): ShowcaseScene | null {
const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
return "environments";
}
if (routePath.endsWith("/terminal")) return "terminal";
if (routePath.endsWith("/review")) return "review";
if (routePath.startsWith("/threads/")) return "thread";
if (routePath === "/") return "threads";
return null;
}

export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) {
if (!SHOWCASE_ENABLED) return null;
Expand Down Expand Up @@ -92,14 +84,11 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
const [agentActivityStaged, setAgentActivityStaged] = useState(false);
const requestedSceneRef = useRef<ShowcaseScene | null>(null);
// Read current entities without restarting on shell updates and re-entering open permission prompts.
const entitiesRef = useRef({ threads, projects });
useEffect(() => {
entitiesRef.current = { threads, projects };
}, [projects, threads]);
const renderSignal = useSyncExternalStore(
const readFixtureEntities = useEffectEvent(() => ({ threads, projects }));
const { signal: renderSignal, reports: renderReports } = useSyncExternalStore(
subscribeToShowcaseRenderSignal,
getShowcaseRenderSignal,
getShowcaseRenderSignal,
getShowcaseRenderSnapshot,
getShowcaseRenderSnapshot,
);

useEffect(() => {
Expand Down Expand Up @@ -152,10 +141,11 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
return () => clearInterval(interval);
}, []);

const themeApplied =
requestedTheme === null
? themeRequestSettled
: themeIds.light === requestedTheme && themeIds.dark === requestedTheme;
const themeApplied = isShowcaseThemeApplied({
launchRequestRead: themeRequestSettled,
requestedTheme,
themeIds,
});

useEffect(() => {
if (requestedTheme === null || themeApplied || !appearancePreferencesReady) {
Expand Down Expand Up @@ -184,9 +174,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
};
}, [connectPairingUrl, pairingUrls]);

const routeScene = sceneFromPathname(props.pathname);
const scene =
requestedScene === "agent-activity" && routeScene === "threads" ? "agent-activity" : routeScene;
const scene = showcaseSceneOnScreen(requestedScene, props.pathname);
const hasServerFixture =
workspace.state.hasReadyEnvironment &&
workspace.environments.length >= 3 &&
Expand Down Expand Up @@ -264,7 +252,7 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
void retryShowcaseOperation(
async () => {
const now = Date.now();
const { threads: latestThreads, projects: latestProjects } = entitiesRef.current;
const { threads: latestThreads, projects: latestProjects } = readFixtureEntities();
const activity = buildShowcaseAgentActivity(latestThreads, latestProjects, now);
const outcome =
activity === null
Expand All @@ -285,21 +273,22 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
};
}, [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 (
scene === null ||
requestedScene === null ||
scene !== requestedScene ||
!hasFixture ||
!orientationSettled ||
!themeApplied ||
(scene === "agent-activity" && !agentActivityStaged) ||
!isShowcaseNativeContentReady({ scene, themeId, renderSignal })
) {
// oxlint-disable-next-line react/set-state-in-effect -- Capture readiness clears while the scene, fixture, or native content is not ready.
setReadyScene(null);
return;
}
if (readinessKey === null || scene === null) return;
if (scene === "terminal") Keyboard.dismiss();

let renderFrame: number | null = null;
Expand All @@ -317,19 +306,9 @@ function EnabledShowcaseCaptureCoordinator(props: { readonly pathname: string })
if (renderFrame !== null) cancelAnimationFrame(renderFrame);
if (readyFrame !== null) cancelAnimationFrame(readyFrame);
};
}, [
agentActivityStaged,
hasFixture,
orientationSettled,
renderSignal,
requestedScene,
scene,
themeApplied,
themeId,
]);
}, [readinessKey, scene]);

if (readyScene === null) return null;

return (
<View
pointerEvents="none"
Expand Down
86 changes: 86 additions & 0 deletions apps/mobile/src/features/showcase/showcaseCaptureModel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vite-plus/test";

import {
isShowcaseThemeApplied,
showcaseReadinessKey,
showcaseSceneOnScreen,
type ShowcaseReadinessInput,
} from "./showcaseCaptureModel";

const ready: ShowcaseReadinessInput = {
scene: "thread",
requestedScene: "thread",
hasFixture: true,
orientationSettled: true,
themeApplied: true,
agentActivityStaged: false,
themeId: "t3-code",
renderSignal: null,
renderReports: 0,
};

describe("showcaseSceneOnScreen", () => {
it("treats the threads list as the agent activity scene only when that scene is requested", () => {
expect(showcaseSceneOnScreen("agent-activity", "/")).toBe("agent-activity");
expect(showcaseSceneOnScreen("threads", "/")).toBe("threads");
expect(showcaseSceneOnScreen("agent-activity", "/threads/env/thread")).toBe("thread");
expect(showcaseSceneOnScreen(null, "/threads/env/thread/review?x=1")).toBe("review");
});
});

describe("isShowcaseThemeApplied", () => {
it("waits for the launch request when no theme is requested", () => {
const themeIds = { light: "t3-code", dark: "t3-code" } as const;
expect(
isShowcaseThemeApplied({ launchRequestRead: false, requestedTheme: null, themeIds }),
).toBe(false);
expect(
isShowcaseThemeApplied({ launchRequestRead: true, requestedTheme: null, themeIds }),
).toBe(true);
});

it("requires both appearances to use the requested theme", () => {
expect(
isShowcaseThemeApplied({
launchRequestRead: true,
requestedTheme: "t3-code",
themeIds: { light: "t3-code", dark: "material-you" },
}),
).toBe(false);
});
});

describe("showcaseReadinessKey", () => {
it("is null until every precondition holds", () => {
expect(showcaseReadinessKey(ready)).not.toBeNull();
expect(showcaseReadinessKey({ ...ready, requestedScene: "terminal" })).toBeNull();
expect(showcaseReadinessKey({ ...ready, hasFixture: false })).toBeNull();
expect(showcaseReadinessKey({ ...ready, orientationSettled: false })).toBeNull();
expect(showcaseReadinessKey({ ...ready, themeApplied: false })).toBeNull();
});

it("waits for agent activity to be staged", () => {
const agentActivity = {
...ready,
scene: "agent-activity",
requestedScene: "agent-activity",
} as const;
expect(showcaseReadinessKey(agentActivity)).toBeNull();
expect(showcaseReadinessKey({ ...agentActivity, agentActivityStaged: true })).not.toBeNull();
});

it("waits for review to report a draw in the current theme, and changes key when it redraws", () => {
const review = { ...ready, scene: "review", requestedScene: "review" } as const;
expect(showcaseReadinessKey(review)).toBeNull();
expect(
showcaseReadinessKey({
...review,
renderSignal: { scene: "review", themeId: "material-you" },
}),
).toBeNull();
const drawn = { ...review, renderSignal: { scene: "review", themeId: "t3-code" } } as const;
const firstDraw = showcaseReadinessKey({ ...drawn, renderReports: 1 });
expect(firstDraw).not.toBeNull();
expect(showcaseReadinessKey({ ...drawn, renderReports: 2 })).not.toBe(firstDraw);
});
});
76 changes: 76 additions & 0 deletions apps/mobile/src/features/showcase/showcaseCaptureModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { MobileThemeId, MobileThemeIds } from "../../lib/mobileTheme";
import type { ShowcaseScene } from "./nativeShowcaseScene";
import { isShowcaseNativeContentReady, type ShowcaseRenderSignal } from "./showcaseRenderSignal";

export const SHOWCASE_THREAD_ID = "remote-command-center";

function sceneFromPathname(pathname: string): ShowcaseScene | null {
const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
return "environments";
}
if (routePath.endsWith("/terminal")) return "terminal";
if (routePath.endsWith("/review")) return "review";
if (routePath.startsWith("/threads/")) return "thread";
if (routePath === "/") return "threads";
return null;
}

/** Agent activity is staged on the threads list, so that route counts as the scene. */
export function showcaseSceneOnScreen(
requestedScene: ShowcaseScene | null,
pathname: string,
): ShowcaseScene | null {
const routeScene = sceneFromPathname(pathname);
return requestedScene === "agent-activity" && routeScene === "threads"
? "agent-activity"
: routeScene;
}

export function isShowcaseThemeApplied(input: {
readonly launchRequestRead: boolean;
readonly requestedTheme: MobileThemeId | null;
readonly themeIds: MobileThemeIds;
}): boolean {
if (input.requestedTheme === null) return input.launchRequestRead;
return (
input.themeIds.light === input.requestedTheme && input.themeIds.dark === input.requestedTheme
);
}

export interface ShowcaseReadinessInput {
readonly scene: ShowcaseScene | null;
readonly requestedScene: ShowcaseScene | null;
readonly hasFixture: boolean;
readonly orientationSettled: boolean;
readonly themeApplied: boolean;
readonly agentActivityStaged: boolean;
readonly themeId: MobileThemeId;
readonly renderSignal: ShowcaseRenderSignal | null;
/** Counts render-signal changes; a redraw report restarts the settle delay. */
readonly renderReports: number;
}

/**
* The capture key for a scene that is ready to be marked, or null while any
* precondition is missing. A changed key restarts the settle delay.
*/
export function showcaseReadinessKey(input: ShowcaseReadinessInput): string | null {
const { scene } = input;
if (
scene === null ||
scene !== input.requestedScene ||
!input.hasFixture ||
!input.orientationSettled ||
!input.themeApplied ||
(scene === "agent-activity" && !input.agentActivityStaged) ||
!isShowcaseNativeContentReady({
scene,
themeId: input.themeId,
renderSignal: input.renderSignal,
})
) {
return null;
}
return [scene, input.themeId, input.renderReports].join("|");
}
18 changes: 12 additions & 6 deletions apps/mobile/src/features/showcase/showcaseRenderSignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ export type ShowcaseRenderSignal = Readonly<{
}>;

const listeners = new Set<() => void>();
let renderSignal: ShowcaseRenderSignal | null = null;
export type ShowcaseRenderSnapshot = Readonly<{
signal: ShowcaseRenderSignal | null;
// A redraw can repeat the same scene and theme; counting reports lets it restart the settle delay.
reports: number;
}>;

let snapshot: ShowcaseRenderSnapshot = { signal: null, reports: 0 };

export function getShowcaseRenderSignal(): ShowcaseRenderSignal | null {
return renderSignal;
export function getShowcaseRenderSnapshot(): ShowcaseRenderSnapshot {
return snapshot;
}

export function subscribeToShowcaseRenderSignal(listener: () => void): () => void {
Expand All @@ -19,13 +25,13 @@ export function subscribeToShowcaseRenderSignal(listener: () => void): () => voi
}

export function reportShowcaseSceneRendered(signal: ShowcaseRenderSignal): void {
renderSignal = signal;
snapshot = { signal, reports: snapshot.reports + 1 };
for (const listener of listeners) listener();
}

export function clearShowcaseRenderSignal(): void {
if (renderSignal === null) return;
renderSignal = null;
if (snapshot.signal === null) return;
snapshot = { signal: null, reports: snapshot.reports + 1 };
for (const listener of listeners) listener();
}

Expand Down
Loading