From e338a9c561a82a5fef0be1c6e2d1edb2a4581f4b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 11:17:38 +0300 Subject: [PATCH] fix(ui): nested repositories open diffs and report status from the selected repo Opening a file from the Git panel while a nested repository was selected created the diff tab under the repository path, a key the context panel never displays. Tabs are keyed by the project root; the diff surface resolves the selected nested repository itself. The work-status Project section now reads branch, changes, and PR from the same resolved repository as the Git tab and names the nested folder under the branch so the reader knows which repository the readouts describe. --- .../work-status/WorkStatusPrimaryGroup.tsx | 50 +++++++++++++++---- packages/ui/src/components/views/GitView.tsx | 10 ++-- packages/ui/src/stores/DOCUMENTATION.md | 2 +- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index bfe046cf..fc0cada6 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; import { useSessionMessages } from '@/sync/sync-context'; @@ -51,37 +52,54 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, const fetchStatus = useGitStore((state) => state.fetchStatus); const clearDiffCache = useGitStore((state) => state.clearDiffCache); + // The repository the readouts describe. Same resolution as the Git tab: the + // session directory itself when it is a repository, otherwise the nested + // repository selected (or auto-selected) for it, so a session in a plain + // folder of repositories still reports the branch and changes the Git tab + // shows. Navigation below stays keyed on `directory` — context-panel tabs + // are per project root. + const { gitDirectory } = useNestedGitDirectory(directory, { enabled: showRepository }); + const gitStatus = useGitStore( React.useCallback( - (state) => (directory ? state.directories.get(directory)?.status ?? null : null), - [directory], + (state) => (gitDirectory ? state.directories.get(gitDirectory)?.status ?? null : null), + [gitDirectory], ), ); // Warm the shared git cache through the background-network gate so the panel // never competes with the chat's own bootstrap traffic for sockets. React.useEffect(() => { - if (!showRepository || !directory || !git) return; - void runBackgroundNetworkTask(() => ensureStatus(directory, git)); - }, [directory, git, ensureStatus, showRepository]); + if (!showRepository || !gitDirectory || !git) return; + void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git)); + }, [gitDirectory, git, ensureStatus, showRepository]); // Own the live invalidation for the repository readout. The desktop // composer's changed-files row no longer renders, so this panel must not // depend on ChatInput (or an opened Git surface) to refresh the shared cache // on its behalf. React.useEffect(() => { - if (!showRepository || !directory || !git) return; + if (!showRepository || !gitDirectory || !git) return; return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== normalizePath(directory)) return; + if (normalizePath(hint.directory) !== normalizePath(gitDirectory)) return; if (hint.paths?.length) { - clearDiffCache(directory, hint.paths); + clearDiffCache(gitDirectory, hint.paths); } - void fetchStatus(directory, git, { silent: true }); + void fetchStatus(gitDirectory, git, { silent: true }); }); - }, [clearDiffCache, directory, fetchStatus, git, showRepository]); + }, [clearDiffCache, gitDirectory, fetchStatus, git, showRepository]); const branch = gitStatus?.current?.trim() || null; + // Which repository under the project the branch belongs to. Only meaningful + // when the readouts come from a nested repository; for a project that is a + // repository itself the section header already names it. + const nestedRepoLabel = React.useMemo(() => { + if (!directory || !gitDirectory || gitDirectory === directory) return null; + const rootPrefix = `${directory}/`; + return gitDirectory.startsWith(rootPrefix) ? gitDirectory.slice(rootPrefix.length) : gitDirectory; + }, [directory, gitDirectory]); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); // Worktrees normally sit beside rather than beneath their project directory, // so a prefix match alone cannot find their owning project. Reuse the shared @@ -103,7 +121,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, // Read-only: PR watching is owned by the background tracker. Starting a watch // here would multiply GitHub requests per open session, which is exactly the // fan-out the PR-status concurrency gate exists to prevent. - const prSummary = useFreshestPrVisualSummaryForBranch(directory, branch); + const prSummary = useFreshestPrVisualSummaryForBranch(gitDirectory, branch); // `getCurrentModel` is an imperative getter: its reference never changes, so // calling it in render subscribes to nothing. Subscribe to the selected model @@ -274,6 +292,16 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, /> ) : null} + {nestedRepoLabel ? ( + openSurface('git') : undefined} + ariaLabel={t('chat.workStatus.action.openGit')} + label={nestedRepoLabel} + muted + /> + ) : null} + {changed ? ( = ({ isActive }) => { [handleRevertPaths] ); + // Context-panel tabs are keyed by the project root, not by the repository + // being diffed: the diff surface resolves the selected nested repository on + // its own, so opening the tab under `gitDirectory` would park it under a key + // the panel never displays. const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => { - if (gitDirectory && !isMobile) { - openContextDiff(gitDirectory, path, staged); + if (currentDirectory && !isMobile) { + openContextDiff(currentDirectory, path, staged); return; } navigateToDiff(path, staged); - }, [gitDirectory, isMobile, navigateToDiff, openContextDiff]); + }, [currentDirectory, isMobile, navigateToDiff, openContextDiff]); const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 2fbce401..ac82998a 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -148,7 +148,7 @@ Important properties: - loading state is per-directory, not global - `ensureStatus()` and `ensureAll()` are the preferred entry points for consumers - in-flight dedupe exists for status and `ensureAll()`; status dedupe is scoped to the per-directory status mutation revision, so a refresh requested after a mutation never joins a pre-mutation in-flight request -- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states +- nested repository discovery (`nestedReposByRoot`, `nestedRepoSelection`, `ensureNestedRepos`) is per-root state for roots that are not themselves git repositories; discovery failure is a `null` marker (never a valid empty result), a runtime without the discovery route (VS Code) commits an `'unsupported'` marker, and an in-flight discovery whose runtime switched is discarded at commit time instead of repopulating the cleared map. Selections are persisted per runtime + root, and `useEffectiveGitDirectory(root)` resolves the directory git surfaces operate on (`root` when the root is a repository, the selected nested repository otherwise). A selection whose repository fails its probe is dropped and remembered session-only (`staleClearedSelections`) so auto-select does not re-pick it and loop walk+probe; manual picker picks bypass the memory. `hooks/useNestedGitDirectory.ts` owns the resolution flow (root probe, discovery, auto-select, stale-selection recovery) for every consuming surface (Git tab, diff view, pull-request view, walkthrough view, mobile changes, work-status project readout), and `git/NestedRepoResolutionStates.tsx` renders the shared pending/failed/unsupported/empty states - worktree bootstrap polling and session/worktree machinery stay keyed on the project root even while a nested repository is selected; only git data and actions follow the selection - runtime reset replaces all live entries with that runtime's persisted branch seeds and invalidates old completions - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations