diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 8e7ee048587f0..117dbc44ede58 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -361,6 +361,7 @@ function useThreadFilesWorkspace(params: { readonly threadId?: string | string[]; readonly cwd?: string | string[]; readonly projectName?: string | string[]; + readonly path?: string | string[]; }) { const routeEnvironmentId = firstRouteParam(params.environmentId); const routeThreadId = firstRouteParam(params.threadId); @@ -382,6 +383,7 @@ function useThreadFilesWorkspace(params: { cwd: routeCwd ?? selectedThreadCwd ?? project?.workspaceRoot ?? null, environmentId, projectName: routeProjectName ?? project?.title ?? "Files", + relativePath: normalizeRoutePath(params.path), selectedThread, threadId, }; @@ -592,11 +594,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { const { appearance, setCodeWordBreak } = useAppearancePreferences(); const iconColor = useUniwindTheme()["--color-icon"]; const params = props.route.params; - const relativePath = normalizeRoutePath(params.path); const targetLine = normalizeRouteLine(firstRouteParam(params.line)); - const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( - props.route.params, - ); + const { cwd, environmentId, projectName, relativePath, selectedThread, threadId } = + useThreadFilesWorkspace(props.route.params); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c3b46cf8e24c2..517d3ddf2981b 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,7 +15,16 @@ import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3too import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; -import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + use, + useDeferredValue, + useEffect, + useEffectEvent, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { @@ -129,12 +138,48 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - // useFocusEffect re-runs when the renderer changes while focused, so the - // pane always shows this render's content, and its cleanup deactivates on - // blur and unmount. - useFocusEffect(() => - wrappedRender === undefined ? undefined : registerWorkspaceInspector(wrappedRender), - ); + const focusedRef = useRef(false); + const deactivateRef = useRef<(() => void) | null>(null); + + const syncRegistration = useEffectEvent(() => { + if (!focusedRef.current || wrappedRender === undefined) { + deactivateRef.current?.(); + return; + } + deactivateRef.current = registerWorkspaceInspector(wrappedRender); + }); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Navigation focus events own the inspector registration lifetime. + useEffect(() => { + if (navigation === undefined) return; + const focus = () => { + if (focusedRef.current) return; + focusedRef.current = true; + syncRegistration(); + }; + const blur = () => { + focusedRef.current = false; + syncRegistration(); + }; + if (navigation.isFocused()) focus(); + const unsubscribeFocus = navigation.addListener("focus", focus); + const unsubscribeBlur = navigation.addListener("blur", blur); + return () => { + unsubscribeFocus(); + unsubscribeBlur(); + focusedRef.current = false; + deactivateRef.current?.(); + deactivateRef.current = null; + }; + }, [navigation]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Changed pane content replaces the focused inspector registration. + useEffect(() => { + if (focusedRef.current) { + syncRegistration(); + } + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. + }, [wrappedRender]); } export function AdaptiveWorkspaceLayout(props: { diff --git a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx index a8cf66463de5b..cd148bff2d969 100644 --- a/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-navigation.test.tsx @@ -1,4 +1,6 @@ +import * as NodePath from "node:path"; import * as NodeModule from "node:module"; +import * as React from "react"; import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; @@ -167,3 +169,120 @@ it("keeps the parent when the maximized child's Agents sheet opens another child expect(screen.open({ type: "thread", ...child, threadId: "grandchild" }, child)).toBe(false); expect(screen.state).toEqual(originalState); }); + +it("keeps a focused inspector registered across renders and updates its content", () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const requireMobile = NodeModule.createRequire(import.meta.url); + const listeners = { focus: new Set<() => void>(), blur: new Set<() => void>() }; + let focused = true; + let activeRender: (() => React.ReactNode) | undefined; + const deactivate = vi.fn(() => { + activeRender = undefined; + }); + const registerWorkspaceInspector = vi.fn((render: () => React.ReactNode) => { + activeRender = render; + return deactivate; + }); + const navigation = { + isFocused: () => focused, + addListener(event: "focus" | "blur", listener: () => void) { + listeners[event].add(listener); + return () => { + listeners[event].delete(listener); + }; + }, + }; + const navigationContext = React.createContext(navigation); + const routeContext = React.createContext({ key: "file" }); + const babel = requireMobile("@babel/core") as { + transformFileSync(file: string, options: Record): { code: string }; + }; + const compiled = babel.transformFileSync( + NodePath.resolve(__dirname, "../layout/AdaptiveWorkspaceLayout.tsx"), + { + babelrc: false, + configFile: false, + presets: [ + [ + requireMobile.resolve("babel-preset-expo"), + { unstable_transformImportMeta: true, enableBabelRuntime: false }, + ], + ], + caller: { + name: "metro", + bundler: "metro", + platform: "ios", + isDev: false, + isServer: false, + isNodeModule: false, + supportsStaticESM: false, + supportsReactCompiler: true, + }, + }, + ); + const exports: { + useRegisterWorkspaceInspector?: (render: (() => React.ReactNode) | undefined) => void; + } = {}; + const load = (name: string) => { + if (name === "react" || name.startsWith("react/")) return requireMobile(name); + if (name === "./adaptive-workspace-context") + return { + useAdaptiveWorkspaceLayout: () => ({ registerWorkspaceInspector }), + }; + if (name === "@react-navigation/native") + return { + NavigationContext: navigationContext, + NavigationRouteContext: routeContext, + useFocusEffect: (callback: () => (() => void) | undefined) => + React.useEffect(callback, [callback]), + }; + return {}; + }; + new Function("require", "exports", compiled.code)(load, exports); + const useRegisterWorkspaceInspector = exports.useRegisterWorkspaceInspector!; + function Screen({ render }: { render: (() => React.ReactNode) | undefined }) { + useRegisterWorkspaceInspector(render); + return null; + } + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + const render = (content: (() => React.ReactNode) | undefined) => + act(() => root.render()); + const first = () => "first"; + render(first); + registerWorkspaceInspector.mockClear(); + deactivate.mockClear(); + render(first); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + expect(deactivate).not.toHaveBeenCalled(); + const second = () => "second"; + render(second); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + const paneContainer = document.createElement("div"); + const paneRoot = createRoot(paneContainer); + using _pane = { [Symbol.dispose]: () => act(() => paneRoot.unmount()) }; + act(() => paneRoot.render(activeRender?.())); + expect(paneContainer.textContent).toBe("second"); + const emit = (event: "focus" | "blur") => + act(() => { + focused = event === "focus"; + for (const listener of listeners[event]) listener(); + }); + emit("blur"); + expect(deactivate).toHaveBeenCalledTimes(1); + registerWorkspaceInspector.mockClear(); + render(first); + expect(registerWorkspaceInspector).not.toHaveBeenCalled(); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + emit("focus"); + expect(registerWorkspaceInspector).toHaveBeenCalledTimes(1); + render(undefined); + expect(activeRender).toBeUndefined(); + render(second); + expect(activeRender).toBeDefined(); + act(() => root.unmount()); + expect(activeRender).toBeUndefined(); + expect(listeners.focus.size + listeners.blur.size).toBe(0); +});