1// Read by evals/mobile-behavior through the Hermes inspector; product modules load lazily so import order is unchanged.
2
3interface Fiber {
4 readonly tag: number;
5 readonly type: unknown;
6 readonly child: Fiber | null;
7 readonly sibling: Fiber | null;
8 readonly memoizedProps: Record<string, unknown> | null;
9}
10interface DevToolsHook {
11 onCommitFiberRoot?: (id: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown;
12 __t3BehaviorProbe?: true;
13}
14interface ListState {
15 readonly scroll: number;
16 readonly scrollLength: number;
17 readonly contentLength: number;
18 readonly isAtEnd: boolean;
19 readonly start: number;
20 readonly end: number;
21 readonly data: ReadonlyArray<{ readonly id?: string; readonly key?: string }>;
22 readonly positionAtIndex: (index: number) => number;
23}
24interface Feed {
25 readonly threadId: string;
26 readonly environmentId: string;
27 readonly list: { readonly current: { getState?: () => ListState } | null } | undefined;
28 following: boolean | null;
29}
30interface Atom {
31 readonly _atom: true;
32}
33
34// Every root React has committed; LogBox and other overlays commit their own.
35const roots = new Set<{ current: Fiber }>();
36
37function installCommitHook() {
38 const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: DevToolsHook })
39 .__REACT_DEVTOOLS_GLOBAL_HOOK__;
40 if (!hook || typeof hook.onCommitFiberRoot !== "function") {
41 setTimeout(installCommitHook, 50);
42 return;
43 }
44 if (hook.__t3BehaviorProbe) return;
45 hook.__t3BehaviorProbe = true;
46 const original = hook.onCommitFiberRoot.bind(hook);
47 hook.onCommitFiberRoot = (id, next, ...rest) => {
48 roots.add(next);
49 return original(id, next, ...rest);
50 };
51}
52
53/** Visits every mounted fiber; `enter` returns the context passed to the fiber's children. */
54function walk<C>(fiber: Fiber | null, context: C, enter: (fiber: Fiber, context: C) => C) {
55 let node = fiber;
56 while (node) {
57 const next = enter(node, context);
58 if (node.child) walk(node.child, next, enter);
59 node = node.sibling;
60 }
61}
62
63const readAtom = <T>(atom: Atom): T => {
64 const { appAtomRegistry } = require("../../state/atom-registry") as {
65 appAtomRegistry: { get: (atom: Atom) => unknown };
66 };
67 return appAtomRegistry.get(atom) as T;
68};
69
70/** Mounted thread feeds, the composer text the native editor was given, and the status pill. */
71function scanTree() {
72 const feeds = new Map<string, Feed>();
73 const editors: Array<{ value: string; expanded: boolean }> = [];
74 let floatingStatus: string | null = null;
75 let homeList: Array<{
76 type: string;
77 threadId?: string;
78 status?: string;
79 pinned?: boolean;
80 count?: number;
81 expanded?: boolean;
82 }> | null = null;
83 const enter = (fiber: Fiber, feed: Feed | null): Feed | null => {
84 const props = fiber.memoizedProps;
85 if (!props || typeof props !== "object") return feed;
86 if ("listRef" in props && typeof props.threadId === "string" && "detailRef" in props) {
87 const existing = feeds.get(props.threadId);
88 if (existing) return existing;
89 const created: Feed = {
90 threadId: props.threadId,
91 environmentId: String(props.environmentId),
92 list: props.listRef as Feed["list"],
93 following: null,
94 };
95 feeds.set(props.threadId, created);
96 return created;
97 }
98 if (feed && "maintainScrollAtEnd" in props && "maintainVisibleContentPosition" in props)
99 feed.following = props.maintainScrollAtEnd !== false;
100 if (typeof props.value === "string" && "chipsInert" in props)
101 editors.push({ value: props.value, expanded: props.chipsInert === false });
102 if ("status" in props && "showScrollToEnd" in props) {
103 const status = props.status as { kind?: string } | null;
104 floatingStatus = status?.kind ?? null;
105 }
106 if (homeList === null && Array.isArray(props.data) && "renderItem" in props) {
107 const data = props.data as ReadonlyArray<{
108 type?: string;
109 item?: { thread?: { id?: string }; pinned?: boolean };
110 count?: number;
111 expanded?: boolean;
112 }>;
113 if (data.some((entry) => entry?.type?.startsWith("v2-"))) {
114 const { resolveThreadListV2Status } = require("../threads/threadListV2") as {
115 resolveThreadListV2Status: (thread: unknown) => string;
116 };
117 homeList = data.map((entry) => ({
118 type: String(entry.type),
119 ...(entry.item?.thread?.id
120 ? {
121 threadId: entry.item.thread.id,
122 status: resolveThreadListV2Status(entry.item.thread),
123 pinned: entry.item.pinned === true,
124 }
125 : {}),
126 ...(typeof entry.count === "number" ? { count: entry.count } : {}),
127 ...(typeof entry.expanded === "boolean" ? { expanded: entry.expanded } : {}),
128 }));
129 }
130 }
131 return feed;
132 };
133 for (const root of roots) walk(root.current, null, enter);
134 return { feeds: [...feeds.values()], editors, floatingStatus, homeList };
135}
136
137function feedState(feed: Feed) {
138 const state = feed.list?.current?.getState?.();
139 if (!state) return { threadId: feed.threadId, ready: false as const };
140 const start = Math.max(0, state.start);
141 const end = Math.min(state.data.length - 1, state.end);
142 const rows: Record<string, number> = {};
143 for (let index = start; index <= end; index++) {
144 const row = state.data[index];
145 const key = row?.id ?? row?.key;
146 if (key) rows[key] = Math.round(state.positionAtIndex(index) - state.scroll);
147 }
148 return {
149 threadId: feed.threadId,
150 ready: true as const,
151 scroll: Math.round(state.scroll),
152 viewport: Math.round(state.scrollLength),
153 content: Math.round(state.contentLength),
154 fromEnd: Math.round(state.contentLength - state.scroll - state.scrollLength),
155 atEnd: state.isAtEnd,
156 following: feed.following,
157 count: state.data.length,
158 /** Screen y of each visible row, keyed by row id. */
159 rows,
160 };
161}
162
163function route() {
164 const { rootNavigationRef } =
165 require("../../lib/rootNavigation") as typeof import("../../lib/rootNavigation");
166 if (!rootNavigationRef.isReady()) return null;
167 const current = rootNavigationRef.getCurrentRoute();
168 const { workspaceHasColumns } = require("../layout/workspace/workspace-selection-store") as {
169 workspaceHasColumns: () => boolean;
170 };
171 return {
172 name: current?.name ?? null,
173 params: (current?.params ?? {}) as Record<string, unknown>,
174 columns: workspaceHasColumns(),
175 };
176}
177
178function snapshot() {
179 const scan = scanTree();
180 const at = route();
181 const threadId = typeof at?.params.threadId === "string" ? at.params.threadId : null;
182 const environmentId =
183 typeof at?.params.environmentId === "string" ? at.params.environmentId : null;
184 const threadKey = threadId && environmentId ? `${environmentId}:${threadId}` : null;
185 const { composerDraftsAtom } = require("../../state/use-composer-drafts") as {
186 composerDraftsAtom: Atom;
187 };
188 const { threadComposerErrorsAtom } = require("../../state/thread-composer-error") as {
189 threadComposerErrorsAtom: Atom;
190 };
191 const drafts = readAtom<Record<string, { text?: string }>>(composerDraftsAtom);
192 const errors = readAtom<Record<string, { message: string }>>(threadComposerErrorsAtom);
193 const { KeyboardController } = require("react-native-keyboard-controller") as {
194 KeyboardController: { isVisible: () => boolean };
195 };
196 const feed = visibleFeed(scan.feeds);
197 const { environmentThreadShells } = require("../../state/threads") as {
198 environmentThreadShells: { threadShellsAtom: Atom };
199 };
200 const { resolveThreadListV2Status } = require("../threads/threadListV2") as {
201 resolveThreadListV2Status: (thread: unknown) => string;
202 };
203 const shells = readAtom<ReadonlyArray<{ id: string; environmentId: string }>>(
204 environmentThreadShells.threadShellsAtom,
205 );
206 return {
207 /** Saved environments the app has threads from. */
208 environments: [...new Set(shells.map((shell) => shell.environmentId))],
209 /** The app's own status for every thread it knows, including rows folded into a shelf. */
210 threads: Object.fromEntries(
211 shells.map((shell) => [shell.id, resolveThreadListV2Status(shell)]),
212 ),
213 route: at,
214 feed: feed ? feedState(feed) : null,
215 composer: {
216 key: threadKey,
217 draft: threadKey ? (drafts[threadKey]?.text ?? "") : null,
218 drafts: Object.fromEntries(
219 Object.entries(drafts).map(([key, draft]) => [key, draft.text ?? ""]),
220 ),
221 editors: scan.editors,
222 error: threadKey ? (errors[threadKey]?.message ?? null) : null,
223 keyboard: KeyboardController.isVisible(),
224 },
225 floatingStatus: scan.floatingStatus,
226 homeList: scan.homeList,
227 };
228}
229
230const frames = { js: [] as number[], on: false };
231
232function jsFrame(time: number) {
233 if (!frames.on) return;
234 frames.js.push(time);
235 requestAnimationFrame(jsFrame);
236}
237
238function uiFramesStart() {
239 "worklet";
240 const ui = globalThis as { __t3UiFrames?: number[]; __t3UiFramesOn?: boolean };
241 ui.__t3UiFrames = [];
242 ui.__t3UiFramesOn = true;
243 const tick = (time: number) => {
244 if (!ui.__t3UiFramesOn) return;
245 ui.__t3UiFrames!.push(time);
246 requestAnimationFrame(tick);
247 };
248 requestAnimationFrame(tick);
249}
250
251function uiFramesStop() {
252 "worklet";
253 const ui = globalThis as { __t3UiFrames?: number[]; __t3UiFramesOn?: boolean };
254 ui.__t3UiFramesOn = false;
255 return ui.__t3UiFrames ?? [];
256}
257
258let samples: Array<Record<string, unknown>> = [];
259let sampled: Feed | null = null;
260
261// The feed is found once per recording; a tree walk on every frame would itself cause jank.
262function sampleFeed(time: number) {
263 if (!sampled) return;
264 const { following: _following, ...state } = feedState(sampled);
265 samples.push({ at: time, ...state });
266 requestAnimationFrame(sampleFeed);
267}
268
269function visibleFeed(feeds: ReadonlyArray<Feed>) {
270 const threadId = route()?.params.threadId;
271 return feeds.find((feed) => feed.threadId === threadId) ?? feeds.at(-1) ?? null;
272}
273
274installCommitHook();
275
276Object.assign(globalThis, {
277 __t3Behavior: {
278 open(url: string) {
279 const { Linking } = require("react-native") as typeof import("react-native");
280 void Linking.openURL(url);
281 return true;
282 },
283 /** Restarts the JS app; deferred so the inspector call can reply first. */
284 reload() {
285 const { DevSettings } = require("react-native") as typeof import("react-native");
286 setTimeout(() => DevSettings.reload(), 100);
287 return true;
288 },
289 state: () => JSON.stringify(snapshot()),
290 /** Removes these saved environments (and their cached threads) through the app's own command. */
291 async forgetEnvironments(ids: ReadonlyArray<string>) {
292 const { runAtomCommand } = require("@t3tools/client-runtime/state/runtime") as {
293 runAtomCommand: (
294 registry: unknown,
295 command: unknown,
296 value: unknown,
297 options: object,
298 ) => Promise<unknown>;
299 };
300 const { appAtomRegistry } = require("../../state/atom-registry") as {
301 appAtomRegistry: unknown;
302 };
303 const { environmentCatalog } = require("../../connection/catalog") as {
304 environmentCatalog: { remove: unknown };
305 };
306 for (const id of ids)
307 await runAtomCommand(appAtomRegistry, environmentCatalog.remove, id, {
308 label: "behavior probe",
309 });
310 return true;
311 },
312 /** Records JS and UI frame times, and the visible feed on every JS frame. */
313 recordStart(withFeed: boolean) {
314 const { scheduleOnUI } =
315 require("react-native-worklets") as typeof import("react-native-worklets");
316 frames.js = [];
317 frames.on = true;
318 requestAnimationFrame(jsFrame);
319 scheduleOnUI(uiFramesStart);
320 samples = [];
321 sampled = withFeed ? visibleFeed(scanTree().feeds) : null;
322 if (sampled) requestAnimationFrame(sampleFeed);
323 return true;
324 },
325 recordStop() {
326 const { runOnUISync } =
327 require("react-native-worklets") as typeof import("react-native-worklets");
328 frames.on = false;
329 sampled = null;
330 return JSON.stringify({ js: frames.js, ui: runOnUISync(uiFramesStop), feed: samples });
331 },
332 },
333});