diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 5756a140e7..057ceaf0a2 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -38,6 +38,8 @@ import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; import { useSelectWorkspaceDetail } from "../workspace/useSelectWorkspaceDetail"; export function HomeRouteScreen() { + globalThis.__captureCounts && (globalThis.__captureCounts.HomeRouteScreen = (globalThis.__captureCounts.HomeRouteScreen ?? 0) + 1); + const { width: windowWidth } = useWindowDimensions(); const nativePrimaryColumn = use(NativePrimaryColumnContext); const columnMetrics = useNativeColumnLayoutMetrics(); diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index e9a71f2c25..505f325c3d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -239,6 +239,8 @@ function renderHomeScrollView(props: ScrollViewProps) { } export function HomeScreen(props: HomeScreenProps) { + globalThis.__captureCounts && (globalThis.__captureCounts.HomeScreen = (globalThis.__captureCounts.HomeScreen ?? 0) + 1); + const usesNativeWorkspaceColumns = useNativeWorkspaceColumnsSupported(); const workspace = useWorkspaceTabs(); const [initialScrollOffset] = useState(() => workspace?.getScrollOffset("threads") ?? 0); diff --git a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx index ff8865e166..957bc51fbc 100644 --- a/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadArrangementSheet.tsx @@ -161,6 +161,8 @@ function DragHandle(props: { } export function ThreadArrangementSheet(props: { onClose: () => void }) { + globalThis.__captureCounts && (globalThis.__captureCounts.ThreadArrangementSheet = (globalThis.__captureCounts.ThreadArrangementSheet ?? 0) + 1); + const threads = useAtomValue(environmentThreadShells.navigationThreadShellsAtom); const configs = useAtomValue(environmentServerConfigsAtom); const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index ce5ad0b6e8..3a4d697bb7 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -133,6 +133,8 @@ function ThreadNavigationSidebarPane( props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean }, ) { const { themeVariables: materialTheme } = useAppearancePreferences(); + globalThis.__captureCounts && (globalThis.__captureCounts.ThreadNavigationSidebarPane = (globalThis.__captureCounts.ThreadNavigationSidebarPane ?? 0) + 1); + const drawerColor = materialTheme["--color-drawer"]; const insets = useSafeAreaInsets(); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 031b2bd529..f8e5178469 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -626,6 +626,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly simultaneousSwipeGesture?: ComponentProps["simultaneousWith"]; }) { const { width: windowWidth, fontScale } = useWindowDimensions(); + globalThis.__captureCounts && (globalThis.__captureCounts.ThreadListV2Row = (globalThis.__captureCounts.ThreadListV2Row ?? 0) + 1); + const titleTypography = useScaledTextRole("body"); const metadataTypography = useScaledTextRole("label"); const projectIconSize = titleTypography.fontSize * 1.25 * fontScale; diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index dffdb251dd..7653bd86d6 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -11,6 +11,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import * as DateTime from "effect/DateTime"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect } from "react"; @@ -35,9 +36,11 @@ export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAt export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); +export const captureSnapshotAtom = Atom.family((_id: EnvironmentId) => Atom.make(null)); +const captureOrLiveSnapshotAtom = Atom.family((id: EnvironmentId) => Atom.make((get) => get(captureSnapshotAtom(id)) ?? get(threadEnvironment.snapshotAtom(id)))); export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, - snapshotAtom: threadEnvironment.snapshotAtom, + snapshotAtom: captureOrLiveSnapshotAtom, }); /** Keeps the subagent tree under `rootThreadId` in the thread list while the caller shows it. */ @@ -82,3 +85,52 @@ export function useEnvironmentThread( ) as EnvironmentThreadState; return state; } + +// Local-only evidence instrumentation. Counts render executions, not frame timing. +let captureUnsubs: Array<() => void> = []; +Object.assign(globalThis, { + __captureStart(environmentId: EnvironmentId) { + captureUnsubs.forEach((f) => f()); + globalThis.__captureCounts = {}; + globalThis.__captureShellTicks = 0; + globalThis.__captureNavigationTicks = 0; + captureUnsubs = [ + appAtomRegistry.subscribe(environmentThreadShells.environmentThreadsAtom(environmentId), () => globalThis.__captureShellTicks++), + appAtomRegistry.subscribe(environmentThreadShells.navigationThreadShellsAtom, () => globalThis.__captureNavigationTicks++), + ]; + return true; + }, + __captureStop() { + captureUnsubs.forEach((f) => f()); + captureUnsubs = []; + const result = { renders: globalThis.__captureCounts, shellUpdates: globalThis.__captureShellTicks, navigationNotifications: globalThis.__captureNavigationTicks }; + globalThis.__captureCounts = null; + return result; + }, + async __capturePureTicks(environmentId: EnvironmentId, count = 100) { + const snapshot = appAtomRegistry.get(threadEnvironment.snapshotAtom(environmentId)); + if (!snapshot) throw new Error('No connected snapshot'); + const base = snapshot.threads.find((t) => t.id === 'capture-garden-a'); + if (!base) throw new Error('No seeded A'); + const running = { ...base, status: 'running', activeRunId: base.latestRunId ?? 'capture-run', activityRunStatus: 'running', itemCount: Math.max(1, base.itemCount), visibleItemCount: Math.max(1, base.visibleItemCount) }; + const makeSnapshot = (tick) => ({ ...snapshot, snapshotSequence: snapshot.snapshotSequence + tick + 1, threads: snapshot.threads.map((t) => t.id === base.id ? { ...running, updatedAt: DateTime.add(base.updatedAt, { seconds: tick }), itemCount: running.itemCount + tick, visibleItemCount: running.visibleItemCount + tick } : t) }); + appAtomRegistry.set(captureSnapshotAtom(environmentId), makeSnapshot(0)); + await new Promise((resolve) => setTimeout(resolve, 250)); + globalThis.__captureStart(environmentId); + for (let tick = 1; tick <= count; tick++) { + appAtomRegistry.set(captureSnapshotAtom(environmentId), makeSnapshot(tick)); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const result = globalThis.__captureStop(); + globalThis.__captureLastPure = result; + return result; + }, + __captureClear(environmentId: EnvironmentId) { + appAtomRegistry.set(captureSnapshotAtom(environmentId), null); + return true; + }, + __captureEnvironment() { + const { PixelRatio, Dimensions, Platform } = require('react-native'); + return { platform: Platform.OS, version: Platform.Version, window: Dimensions.get('window'), screen: Dimensions.get('screen'), dpr: PixelRatio.get(), fontScale: PixelRatio.getFontScale() }; + }, +});