From 677ff5542244daab0fce73e12b7977400599c6c8 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:37:40 -0700 Subject: [PATCH 1/3] refactor(mobile): workspace layout reveals the review and files inspector itself Opening Review or Files in split view revealed the inspector from an effect in each screen (ReviewSheet, ThreadFilesTreeScreen), which set layout state after the workspace had already committed without the inspector, so every open committed the whole workspace tree twice. The layout now derives the reveal in render from the focused workspace route name and thread, once per route and thread, so the first commit already shows the inspector and coming back from a screen pushed over the route keeps the user's choice. useRegisterWorkspaceInspector kept registration in sync with focus through a focus effect, a renderer-change effect, an unmount effect and two refs. One useFocusEffect does the same: it re-runs when the renderer changes while focused, and its cleanup deactivates on blur and unmount. Effects: AdaptiveWorkspaceLayout 5 -> 3, ReviewSheet 1 -> 0, ThreadFilesRouteScreen 1 -> 0. Co-Authored-By: Claude Opus 5.5 --- .../features/files/ThreadFilesRouteScreen.tsx | 12 +--- .../layout/AdaptiveWorkspaceLayout.tsx | 72 ++++++++----------- .../src/features/review/ReviewSheet.tsx | 15 +--- 3 files changed, 33 insertions(+), 66 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index e1b4ac2a26d9d..8e7ee048587f0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -4,7 +4,7 @@ import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/files import { environmentSession } from "../../state/session"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; -import { useEffect, useId, useRef, useState } from "react"; +import { useId, useState } from "react"; import { Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; @@ -431,14 +431,13 @@ function FilesToolbarBottomFade() { export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { useAdaptiveWorkspacePaneRole("inspector"); const navigation = useNavigation(); - const { fileInspector, layout, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { fileInspector, layout } = useAdaptiveWorkspaceLayout(); const [searchQuery, setSearchQuery] = useState(""); const { themeAppearance: highlightTheme } = useAppearancePreferences(); const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace( props.route.params, ); const hasFileInspector = fileInspector.supported && threadId !== null; - const revealedInspectorRef = useRef(false); const fileAccessSession = useEnvironmentQuery( environmentId !== null ? environmentSession.sessionStateAtom(environmentId) : null, ); @@ -511,13 +510,6 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { theme: highlightTheme, }); }; - useEffect(() => { - if (hasFileInspector && cwd !== null && !revealedInspectorRef.current) { - revealedInspectorRef.current = true; - showAuxiliaryPane("inspector"); - } - }, [cwd, hasFileInspector, showAuxiliaryPane]); - if (environmentId === null || (threadId !== null && selectedThread === null)) { if (hasFileInspector) { return ( diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 8ecbdce270938..62cb504d1088e 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -9,17 +9,13 @@ import type { import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { - use, - useDeferredValue, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; + NavigationContext, + NavigationRouteContext, + useNavigation, + useNavigationState, +} from "@react-navigation/native"; +import { use, useDeferredValue, useEffect, 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, { @@ -124,39 +120,11 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi ); })(); - 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); - }); - - useFocusEffect(() => { - focusedRef.current = true; - syncRegistration(); - return () => { - focusedRef.current = false; - syncRegistration(); - }; - }); - - useEffect(() => { - if (focusedRef.current) { - syncRegistration(); - } - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Changed pane content replaces its existing native workspace registration. - }, [wrappedRender]); - - useEffect( - () => () => { - deactivateRef.current?.(); - deactivateRef.current = null; - }, - [], + // 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), ); } @@ -354,6 +322,24 @@ function AdaptiveWorkspaceLayoutContent( return null; } })(); + const workspaceRouteName = useNavigationState( + (state) => state?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, + ); + // Opening a review or the files route reveals its inspector once per route + // and thread. Coming back to that route from a screen pushed over it keeps + // the user's choice, so the last revealed key is not cleared on other routes. + const inspectorRevealKey = + (workspaceRouteName === "ThreadReview" || + (workspaceRouteName === "ThreadFiles" && fileInspector.supported)) && + selectedThreadKey !== null + ? `${props.workspaceRouteKey}:${selectedThreadKey}` + : null; + const [revealedInspectorKey, setRevealedInspectorKey] = useState(null); + if (inspectorRevealKey !== null && inspectorRevealKey !== revealedInspectorKey) { + setRevealedInspectorKey(inspectorRevealKey); + setFocusedAuxiliaryPaneRole("inspector"); + setFileInspectorPreferredVisible(true); + } const [workspaceInspector, setWorkspaceInspector] = useState<{ readonly render: () => ReactNode; readonly active: boolean; diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index 55e4398ec24b6..18908302d577e 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -10,14 +10,7 @@ import type { AppBarMenuItem } from "../../components/ui/AppBar.types"; import type { ReviewSectionItem } from "./reviewModel"; import { useReviewHeaderPresentation } from "./useReviewHeaderPresentation"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; -import { - type Ref, - type ReactElement, - useEffect, - useImperativeHandle, - useRef, - useState, -} from "react"; +import { type Ref, type ReactElement, useImperativeHandle, useRef, useState } from "react"; import { FlatList, Platform, @@ -432,7 +425,7 @@ type ReviewSheetProps = StaticScreenProps<{ export function ReviewSheet(props: ReviewSheetProps) { const { nativeReviewDiffStyle } = useAppearanceCodeSurface(); useAdaptiveWorkspacePaneRole("inspector"); - const { panes, showAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { panes } = useAdaptiveWorkspaceLayout(); const navigation = useNavigation(); const selectDetail = useSelectWorkspaceDetail(); const insets = useSafeAreaInsets(); @@ -451,10 +444,6 @@ export function ReviewSheet(props: ReviewSheetProps) { const bottomContentInset = Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; - useEffect(() => { - showAuxiliaryPane("inspector"); - // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Opening review for another thread reselects its inspector pane. - }, [environmentId, showAuxiliaryPane, threadId]); const { error, reviewSections, From 372727890bb411be4b968b79dc21a816ae4a26fa Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:18:43 -0700 Subject: [PATCH 2/3] fix(mobile): Files reveal waits for a working directory and Back keeps the choice The layout's render-time inspector reveal now gates Files on the same cwd resolution the Files screen uses, so a thread without one no longer hides the sidebar beside "Files unavailable". Reveals are remembered per route key, pruned to the routes still on the stack, so Review, then Files, then Back to Review keeps the user's choice. The focused route name comes from workspaceLocationFromState instead of a second navigation-state subscription. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/Stack.tsx | 7 ++- .../layout/AdaptiveWorkspaceLayout.tsx | 62 ++++++++++++------- .../src/state/use-selected-thread-worktree.ts | 21 ++++++- apps/mobile/src/state/use-thread-detail.ts | 12 ++-- 4 files changed, 73 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 396759a5f04b0..79a98b6604587 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -482,9 +482,12 @@ export function workspaceLocationFromState(state: NavigationState) { ? ({ ...state, routes, index: routes.length - 1 } as NavigationState) : state; const path = getPathFromState(effectiveState, navigationPathConfig); + const route = effectiveState.routes[effectiveState.index]; return { pathname: path.startsWith("/") ? path : `/${path}`, - routeKey: effectiveState.routes[effectiveState.index]?.key, + routeKey: route?.key, + routeName: route?.name, + routeKeys: effectiveState.routes.map((stackRoute) => stackRoute.key), }; } @@ -518,6 +521,8 @@ function RootStackLayout(props: { {props.children} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 62cb504d1088e..9e754c10ae000 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -9,12 +9,7 @@ import type { import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; -import { - NavigationContext, - NavigationRouteContext, - useNavigation, - useNavigationState, -} from "@react-navigation/native"; +import { NavigationContext, NavigationRouteContext, useNavigation } from "@react-navigation/native"; import { use, useDeferredValue, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Keyboard, Platform, useWindowDimensions, View } from "react-native"; import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; @@ -41,6 +36,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import type { ThreadListFilters } from "../../persistence/mobile-preferences"; import { appAtomRegistry } from "../../state/atom-registry"; import { useProjects } from "../../state/entities"; +import { useThreadCwd } from "../../state/use-selected-thread-worktree"; import { environmentThreadShells } from "../../state/threads"; import { mobilePreferencesAtom } from "../../state/preferences"; import { @@ -132,6 +128,8 @@ export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); @@ -158,6 +156,8 @@ function AdaptiveWorkspaceLayoutContent( readonly children: ReactNode; readonly pathname: string; readonly workspaceRouteKey: string | undefined; + readonly workspaceRouteName: string | undefined; + readonly workspaceRouteKeys: ReadonlyArray; readonly selectedPullRequestKey?: string | null; } & { readonly projectGroupingMode: SidebarProjectGroupingMode; @@ -312,31 +312,49 @@ function AdaptiveWorkspaceLayoutContent( const activeThread = parseActiveThreadPath(pathname); const environmentId = activeThread?.environmentId ?? null; const threadId = activeThread?.threadId ?? null; - const selectedThreadKey = (() => { + const selectedThreadRef = (() => { if (environmentId === null || threadId === null) { return null; } try { - return scopedThreadKey(EnvironmentId.make(environmentId), ThreadId.make(threadId)); + return { + environmentId: EnvironmentId.make(environmentId), + threadId: ThreadId.make(threadId), + }; } catch { return null; } })(); - const workspaceRouteName = useNavigationState( - (state) => state?.routes.find((route) => route.key === props.workspaceRouteKey)?.name, + const selectedThreadKey = + selectedThreadRef === null + ? null + : scopedThreadKey(selectedThreadRef.environmentId, selectedThreadRef.threadId); + const isFilesRoute = props.workspaceRouteName === "ThreadFiles"; + // Same resolution the Files screen uses: without a cwd it shows "Files unavailable" and + // registers no inspector, so revealing would only hide the sidebar beside that screen. + const filesCwd = useThreadCwd(isFilesRoute && fileInspector.supported ? selectedThreadRef : null); + // Opening a review or the files route reveals its inspector once per route and thread. + // Routes still on the stack remember their reveal, so going back to one keeps the user's choice. + const revealsInspector = + props.workspaceRouteName === "ThreadReview" || (isFilesRoute && filesCwd !== null); + const [revealedInspectors, setRevealedInspectors] = useState>( + () => new Map(), ); - // Opening a review or the files route reveals its inspector once per route - // and thread. Coming back to that route from a screen pushed over it keeps - // the user's choice, so the last revealed key is not cleared on other routes. - const inspectorRevealKey = - (workspaceRouteName === "ThreadReview" || - (workspaceRouteName === "ThreadFiles" && fileInspector.supported)) && - selectedThreadKey !== null - ? `${props.workspaceRouteKey}:${selectedThreadKey}` - : null; - const [revealedInspectorKey, setRevealedInspectorKey] = useState(null); - if (inspectorRevealKey !== null && inspectorRevealKey !== revealedInspectorKey) { - setRevealedInspectorKey(inspectorRevealKey); + if ( + revealsInspector && + props.workspaceRouteKey !== undefined && + selectedThreadKey !== null && + revealedInspectors.get(props.workspaceRouteKey) !== selectedThreadKey + ) { + const next = new Map(); + for (const routeKey of props.workspaceRouteKeys) { + const revealedThreadKey = revealedInspectors.get(routeKey); + if (revealedThreadKey !== undefined) { + next.set(routeKey, revealedThreadKey); + } + } + next.set(props.workspaceRouteKey, selectedThreadKey); + setRevealedInspectors(next); setFocusedAuxiliaryPaneRole("inspector"); setFileInspectorPreferredVisible(true); } diff --git a/apps/mobile/src/state/use-selected-thread-worktree.ts b/apps/mobile/src/state/use-selected-thread-worktree.ts index 8b1727831f627..2d38115bd85a8 100644 --- a/apps/mobile/src/state/use-selected-thread-worktree.ts +++ b/apps/mobile/src/state/use-selected-thread-worktree.ts @@ -1,6 +1,8 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; import { useMemo } from "react"; -import { useSelectedThreadWorktreePath } from "./use-thread-detail"; +import { useProject, useQuietThreadShell } from "./entities"; +import { useSelectedThreadWorktreePath, useThreadWorktreePath } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext"; @@ -22,3 +24,20 @@ export function useSelectedThreadWorktree() { selectedThreadCwd: selectedThreadWorktreePath ?? selectedThreadProject?.workspaceRoot ?? null, }; } + +/** The selected-thread cwd resolution for a thread that is not the current route's. */ +export function useThreadCwd(ref: ScopedThreadRef | null): string | null { + const shell = useQuietThreadShell(ref); + const detailWorktreePath = useThreadWorktreePath(ref); + const project = useProject( + shell === null ? null : { environmentId: shell.environmentId, projectId: shell.projectId }, + ); + return ( + resolvePreferredThreadWorktreePath({ + threadShellWorktreePath: shell?.worktreePath ?? null, + threadDetailWorktreePath: detailWorktreePath, + }) ?? + project?.workspaceRoot ?? + null + ); +} diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index ad684b3b6a68f..fa69bfae6639a 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -92,15 +92,17 @@ export function useSelectedThreadVisibleTurnItems(): OrchestrationV2ThreadProjec const EMPTY_WORKTREE_PATH_ATOM = Atom.make(null); const EMPTY_PENDING_REQUESTS_ATOM = Atom.make(null); -export function useSelectedThreadWorktreePath() { - const { selectedThreadDetailRef } = useThreadSelection(); +export function useThreadWorktreePath(ref: ScopedThreadRef | null) { return useAtomValue( - selectedThreadDetailRef === null - ? EMPTY_WORKTREE_PATH_ATOM - : environmentThreadDetails.worktreePathAtom(selectedThreadDetailRef), + ref === null ? EMPTY_WORKTREE_PATH_ATOM : environmentThreadDetails.worktreePathAtom(ref), ); } +export function useSelectedThreadWorktreePath() { + const { selectedThreadDetailRef } = useThreadSelection(); + return useThreadWorktreePath(selectedThreadDetailRef); +} + export function useSelectedThreadPendingRequests() { const { selectedThreadDetailRef } = useThreadSelection(); return useAtomValue( From a4404ae63686f08809800e1807cdb904043b5162 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:28:07 -0700 Subject: [PATCH 3/3] fix(mobile): Files reveal resolves the project from loaded thread detail useThreadCwd picked the project only from the thread shell, so a thread whose shell was missing but whose detail was loaded got no cwd and no reveal, while the Files screen fell back to the project root. It now reads the project from the detail too. A compiler coverage case pins that the inspector registration stays cached on its renderer. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/reactCompilerCoverage.test.ts | 10 ++++ .../use-selected-thread-worktree.test.ts | 55 +++++++++++++++++++ .../src/state/use-selected-thread-worktree.ts | 15 ++++- 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/state/use-selected-thread-worktree.test.ts diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 55c653a281622..654ab883d5550 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -182,6 +182,16 @@ describe("React Compiler coverage", () => { ).toEqual([true]); }); + // An uncached registration would re-register the workspace inspector on every render. + it("registers the workspace inspector only when its renderer changes", () => { + expect( + compilerCachedCalls( + "src/features/layout/AdaptiveWorkspaceLayout.tsx", + "registerWorkspaceInspector", + ), + ).toEqual([true]); + }); + it("lists only files that exist", () => { expect(Object.keys(KNOWN_BAILOUTS).filter((file) => !sourceFiles.includes(file))).toEqual([]); }); diff --git a/apps/mobile/src/state/use-selected-thread-worktree.test.ts b/apps/mobile/src/state/use-selected-thread-worktree.test.ts new file mode 100644 index 0000000000000..70577dbcb9d0c --- /dev/null +++ b/apps/mobile/src/state/use-selected-thread-worktree.test.ts @@ -0,0 +1,55 @@ +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + shell: null as { projectId: string; worktreePath: string | null } | null, + detail: null as { thread: { projectId: string } } | null, + detailWorktreePath: null as string | null, + projects: new Map(), +})); + +vi.mock("./entities", () => ({ + useQuietThreadShell: (ref: unknown) => (ref === null ? null : state.shell), + useProject: (ref: { projectId: string } | null) => + ref === null ? null : (state.projects.get(ref.projectId) ?? null), +})); +vi.mock("./threads", async () => { + const { some, none } = await import("effect/Option"); + return { + useEnvironmentThread: (environmentId: unknown, threadId: unknown) => ({ + data: + environmentId !== null && threadId !== null && state.detail ? some(state.detail) : none(), + }), + }; +}); +vi.mock("./use-thread-detail", () => ({ + useThreadWorktreePath: (ref: unknown) => (ref === null ? null : state.detailWorktreePath), + useSelectedThreadWorktreePath: () => null, +})); +vi.mock("./use-thread-selection", () => ({ useThreadSelection: () => ({}) })); + +const { useThreadCwd } = await import("./use-selected-thread-worktree"); + +const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; + +function setup(input: Partial>) { + Object.assign(state, { shell: null, detail: null, detailWorktreePath: null }, input); + state.projects = new Map([[ProjectId.make("project"), { workspaceRoot: "/repo" }]]); +} + +describe("useThreadCwd", () => { + it("falls back to the project root from the loaded detail when the shell is missing", () => { + setup({ detail: { thread: { projectId: "project" } } }); + expect(useThreadCwd(ref)).toBe("/repo"); + }); + + it("prefers the thread worktree over the project root", () => { + setup({ shell: { projectId: "project", worktreePath: "/repo/.worktrees/a" } }); + expect(useThreadCwd(ref)).toBe("/repo/.worktrees/a"); + }); + + it("is null without a thread", () => { + setup({ detail: { thread: { projectId: "project" } } }); + expect(useThreadCwd(null)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/use-selected-thread-worktree.ts b/apps/mobile/src/state/use-selected-thread-worktree.ts index 2d38115bd85a8..72e8cc4b24a6d 100644 --- a/apps/mobile/src/state/use-selected-thread-worktree.ts +++ b/apps/mobile/src/state/use-selected-thread-worktree.ts @@ -1,7 +1,9 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { useMemo } from "react"; import { useProject, useQuietThreadShell } from "./entities"; +import { useEnvironmentThread } from "./threads"; import { useSelectedThreadWorktreePath, useThreadWorktreePath } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { resolvePreferredThreadWorktreePath } from "../features/terminal/terminalLaunchContext"; @@ -25,12 +27,21 @@ export function useSelectedThreadWorktree() { }; } -/** The selected-thread cwd resolution for a thread that is not the current route's. */ +/** + * Resolves a thread's cwd the way the selected thread's does, for a thread that is not the + * current route's: the shell, or the loaded detail when the shell is missing, picks the project. + */ export function useThreadCwd(ref: ScopedThreadRef | null): string | null { const shell = useQuietThreadShell(ref); + const detailState = useEnvironmentThread( + shell === null ? (ref?.environmentId ?? null) : null, + shell === null ? (ref?.threadId ?? null) : null, + ); + const detailThread = Option.getOrNull(detailState.data)?.thread ?? null; const detailWorktreePath = useThreadWorktreePath(ref); + const projectId = shell?.projectId ?? detailThread?.projectId ?? null; const project = useProject( - shell === null ? null : { environmentId: shell.environmentId, projectId: shell.projectId }, + ref === null || projectId === null ? null : { environmentId: ref.environmentId, projectId }, ); return ( resolvePreferredThreadWorktreePath({