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