fix: root monorepo subfolder imports at the git repo while keeping the first terminal in the subfolder (#6336) (#6574)

Add Project → Browse folder on a monorepo subfolder (e.g. /tmp/monorepo/packages/web)
stored the subfolder as Repo.path because isGitRepo() uses
`git rev-parse --is-inside-work-tree`, which is true for any subdirectory.
Authoritative worktree resolution then snapped activation back to the repo root,
so the import identity mismatched and the first terminal landed at the repo root
rather than the selected subfolder.

Canonicalize local git imports to the actual repo root via getGitRepoRoot()
(mirroring the SSH import path that snaps to check.rootPath, including its
post-resolution dedup), and preserve the user-selected subfolder as a one-shot
initial terminal cwd consumed only by the first activation-created pane.
Selecting the repo root applies no override, and re-importing a subfolder of an
existing project dedupes.

Supersedes community PR #6362.

Fixes #6336

Co-authored-by: Rod Boev <rodboev@users.noreply.github.com>
This commit is contained in:
Neil
2026-06-28 16:33:06 -07:00
committed by GitHub
co-authored by Rod Boev
parent ca90af2450
commit adcf74a9fb
23 changed files with 481 additions and 27 deletions
+21 -1
View File
@@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import * as path from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { isGitRepo } from './repo'
import { getGitRepoRoot, isGitRepo } from './repo'
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] })
@@ -34,4 +34,24 @@ describe('isGitRepo', () => {
expect(isGitRepo(bareRepo)).toBe(true)
})
it('resolves a contained path to the worktree root', () => {
const repoRoot = path.join(tmpDir, 'repo')
const nestedDir = path.join(repoRoot, 'packages', 'web')
mkdirSync(nestedDir, { recursive: true })
git(tmpDir, ['init', '--quiet', repoRoot])
// Why: derive the expected root from git's own --show-toplevel so the
// assertion matches getGitRepoRoot's canonicalization (e.g. macOS resolves
// the /var tmpdir symlink to /private/var) across all platforms.
const expectedRoot = git(repoRoot, ['rev-parse', '--show-toplevel']).trim().replace(/\\/g, '/')
expect(getGitRepoRoot(nestedDir)).toBe(expectedRoot)
})
it('preserves bare repository paths when no worktree root exists', () => {
const bareRepo = path.join(tmpDir, 'bare.git')
git(tmpDir, ['init', '--bare', '--quiet', bareRepo])
expect(getGitRepoRoot(bareRepo)).toBe(bareRepo)
})
})
+22
View File
@@ -5,6 +5,7 @@ import { basename } from 'path'
import { gitExecFileSync, gitExecFileAsync } from './runner'
import type { BaseRefSearchResult } from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import {
buildHostedRemoteCommitUrl,
buildHostedRemoteFileUrl,
@@ -89,6 +90,27 @@ export function isGitRepo(path: string): boolean {
}
}
export function getGitRepoRoot(path: string): string {
try {
if (!existsSync(path) || !statSync(path).isDirectory()) {
return path
}
const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], {
cwd: path
}).trim()
if (insideWorkTree === 'true') {
return normalizeRuntimePathSeparators(
gitExecFileSync(['rev-parse', '--show-toplevel'], {
cwd: path
}).trim()
)
}
} catch {
// Fall through to preserving the original path.
}
return path
}
/**
* Get a human-readable name for the repo from its path.
*/
+41 -1
View File
@@ -11,7 +11,7 @@ import { tmpdir } from 'os'
import { join } from 'path'
import type * as RepoModule from '../git/repo'
import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
import { isGitRepo } from '../git/repo'
import { getGitRepoRoot, isGitRepo } from '../git/repo'
const {
handleMock,
@@ -93,6 +93,7 @@ vi.mock('../git/repo', async () => {
...actual,
// Stub only the functions that spawn git / touch the filesystem.
isGitRepo: vi.fn().mockReturnValue(true),
getGitRepoRoot: vi.fn((path: string) => path),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
@@ -185,6 +186,8 @@ describe('projectGroups IPC validation', () => {
listWorktreeGraphMock.mockResolvedValue([])
vi.mocked(isGitRepo).mockReset()
vi.mocked(isGitRepo).mockReturnValue(true)
vi.mocked(getGitRepoRoot).mockReset()
vi.mocked(getGitRepoRoot).mockImplementation((path: string) => path)
mockMultiplexer.notify.mockReset()
mockMultiplexer.request.mockReset()
invalidateAuthorizedRootsCacheMock.mockReset()
@@ -1756,6 +1759,43 @@ describe('repos:add + repos:clone', () => {
)
})
it('canonicalizes local git repos:add to the detected root path', async () => {
vi.mocked(getGitRepoRoot).mockReturnValue('/tmp/from-add')
const result = await handlers.get('repos:add')!(null, {
path: '/tmp/from-add/packages/web',
kind: 'git'
})
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/tmp/from-add',
displayName: 'from-add'
})
)
expect(result).toHaveProperty('repo.path', '/tmp/from-add')
})
it('dedupes local git repos:add after canonical root resolution', async () => {
const existing = {
id: 'repo-add-existing-root',
path: '/tmp/from-add',
displayName: 'from-add',
kind: 'git',
badgeColor: '#22c55e'
}
mockStore.getRepos.mockReturnValue([existing])
vi.mocked(getGitRepoRoot).mockReturnValue('/tmp/from-add')
const result = await handlers.get('repos:add')!(null, {
path: '/tmp/from-add/packages/web',
kind: 'git'
})
expect(result).toEqual({ repo: existing })
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('returns existing badgeColor unchanged on repos:add dedupe', async () => {
const existing = {
id: 'repo-add-existing',
+18 -3
View File
@@ -60,6 +60,7 @@ import {
import { createNestedRepoImportTargetResolver } from '../project-groups/nested-repo-import-target'
import {
isGitRepo,
getGitRepoRoot,
getGitUsername,
getRepoName,
getBaseRefDefault,
@@ -184,6 +185,7 @@ async function addLocalRepoFromPath(
return { error: `Not a valid git repository: ${path}` }
}
const resolvedPath = repoKind === 'git' ? getGitRepoRoot(path) : path
const pathKey = normalizeRuntimePathForComparison(path)
const existing = store
.getRepos()
@@ -192,11 +194,24 @@ async function addLocalRepoFromPath(
return { repo: existing, alreadyExisted: true }
}
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
const resolvedPathKey = normalizeRuntimePathForComparison(resolvedPath)
if (resolvedPathKey !== pathKey) {
const existingAfterRootResolve = store
.getRepos()
.find(
(repo) =>
!repo.connectionId && normalizeRuntimePathForComparison(repo.path) === resolvedPathKey
)
if (existingAfterRootResolve) {
return { repo: existingAfterRootResolve, alreadyExisted: true }
}
}
const detected = await detectRepoIconAndUpstream({ repoPath: resolvedPath, kind: repoKind })
const repo: Repo = {
id: randomUUID(),
path,
displayName: getRepoName(path),
path: resolvedPath,
displayName: getRepoName(resolvedPath),
badgeColor: DEFAULT_REPO_BADGE_COLOR,
...detected,
addedAt: Date.now(),
@@ -34,9 +34,7 @@ export function NativeChatInteractiveCard({
)
// Thread the sibling `toolName` from the same status entry so the question
// parser can dispatch through the tool's registered parser (mobile parity).
const interactiveToolName = useAppStore(
(s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null
)
const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null)
const { sendAnswer, sendRaw, cancel } = send
const card = useMemo(
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_SOURCE_PRIORITY, type NativeChatMessage } from '../../../../shared/native-chat-types'
import {
NATIVE_CHAT_SOURCE_PRIORITY,
type NativeChatMessage
} from '../../../../shared/native-chat-types'
import { mergeNativeChatMessagesWith } from '../../../../shared/native-chat-merge'
import { assembleNativeChatSession } from './native-chat-session-assembler'
@@ -58,12 +58,27 @@ describe('incremental assembler — oracle differential', () => {
// empty batch
[],
// re-emit id 'b' from transcript — supersedes the hook copy in place
[msg({ id: 'b', timestamp: 20, source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })],
[
msg({
id: 'b',
timestamp: 20,
source: 'transcript',
blocks: [{ type: 'text', text: 'final' }]
})
],
// out-of-order timestamp: sorts BEFORE the current tail → forces re-sort
[msg({ id: 'd', timestamp: 5, blocks: [{ type: 'text', text: 'early' }] })],
// turnKey collision with 'a': same role+text+timestamp, DIFFERENT source
// (scrape) — lower priority, must be dropped by the cross-source gate
[msg({ id: 'a-scrape', timestamp: 10, role: 'user', source: 'scrape', blocks: [{ type: 'text', text: 'hello' }] })],
[
msg({
id: 'a-scrape',
timestamp: 10,
role: 'user',
source: 'scrape',
blocks: [{ type: 'text', text: 'hello' }]
})
],
// same-source identical prompt (distinct id) — must NOT collapse (#10)
[msg({ id: 'e', timestamp: 40, role: 'user', blocks: [{ type: 'text', text: 'hello' }] })],
// null timestamp append → forces re-sort, sorts to the front
@@ -157,6 +157,7 @@ describe('AddProjectFromFolderDialog', () => {
expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
repoId: repo.id,
source: 'local_folder_picker',
selectedPath: '/projects/child',
closeModal: mocks.state.closeModal,
setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
})
@@ -206,6 +207,7 @@ describe('AddProjectFromFolderDialog', () => {
expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
repoId: repo.id,
source: 'ssh_remote_path',
selectedPath: '/srv/projects/child',
closeModal: mocks.state.closeModal,
setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
})
@@ -229,6 +231,7 @@ describe('AddProjectFromFolderDialog', () => {
expect(mocks.finishProjectAddWithDefaultCheckout).toHaveBeenCalledWith({
repoId: repo.id,
source: 'local_folder_picker',
selectedPath: '/projects/child',
closeModal: mocks.state.closeModal,
setHideDefaultBranchWorkspace: mocks.state.setHideDefaultBranchWorkspace
})
@@ -118,6 +118,7 @@ const AddProjectFromFolderDialog = React.memo(function AddProjectFromFolderDialo
await finishProjectAddWithDefaultCheckout({
repoId: repo.id,
source: connectionId ? 'ssh_remote_path' : 'local_folder_picker',
selectedPath: folderPath,
closeModal,
setHideDefaultBranchWorkspace
})
@@ -169,6 +169,38 @@ describe('finishProjectAddWithDefaultCheckout', () => {
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo')
})
it('passes a contained selected path through as the initial terminal cwd', async () => {
mocks.state.worktreesByRepo = {
'repo-1': [makeWorktree()]
}
await openProjectDefaultCheckout({
repoId: 'repo-1',
source: 'local_folder_picker',
selectedPath: '/repo/packages/web',
setHideDefaultBranchWorkspace: vi.fn()
})
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo', {
initialCwd: '/repo/packages/web'
})
})
it('skips the initial cwd override when the selected path is the repo root', async () => {
mocks.state.worktreesByRepo = {
'repo-1': [makeWorktree()]
}
await openProjectDefaultCheckout({
repoId: 'repo-1',
source: 'local_folder_picker',
selectedPath: '/repo',
setHideDefaultBranchWorkspace: vi.fn()
})
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('repo-1::/repo')
})
it('shows a hidden detected default checkout before activating it', async () => {
const defaultCheckout = makeWorktree()
mocks.state.detectedWorktreesByRepo = {
@@ -6,6 +6,7 @@ import type {
EventProps
} from '../../../../shared/telemetry-events'
import type { DetectedWorktreeListResult, Worktree } from '../../../../shared/types'
import { relativePathInsideRoot } from '../../../../shared/cross-platform-path'
import { markOnboardingProjectAdded } from '@/lib/onboarding-project-checklist'
import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization'
@@ -94,13 +95,26 @@ async function findDetectedDefaultCheckout(repoId: string): Promise<{
}
}
function resolveInitialCwdForDefaultCheckout(
defaultCheckout: Worktree,
selectedPath: string | undefined
): string | undefined {
if (!selectedPath) {
return undefined
}
const relativePath = relativePathInsideRoot(defaultCheckout.path, selectedPath)
return relativePath && relativePath.length > 0 ? selectedPath : undefined
}
export async function openProjectDefaultCheckout({
repoId,
source,
selectedPath,
setHideDefaultBranchWorkspace
}: {
repoId: string
source: AddRepoDefaultCheckoutHandoffSource
selectedPath?: string
setHideDefaultBranchWorkspace: (value: boolean) => void
}): Promise<void> {
let defaultCheckout = getProjectDefaultCheckout(
@@ -135,7 +149,12 @@ export async function openProjectDefaultCheckout({
result: 'opened_default_checkout',
reason
})
activateAndRevealWorktree(defaultCheckout.id)
const initialCwd = resolveInitialCwdForDefaultCheckout(defaultCheckout, selectedPath)
if (initialCwd) {
activateAndRevealWorktree(defaultCheckout.id, { initialCwd })
} else {
activateAndRevealWorktree(defaultCheckout.id)
}
return
}
@@ -150,15 +169,22 @@ export async function openProjectDefaultCheckout({
export async function finishProjectAddWithDefaultCheckout({
repoId,
source,
selectedPath,
closeModal,
setHideDefaultBranchWorkspace
}: {
repoId: string
source: AddRepoDefaultCheckoutHandoffSource
selectedPath?: string
closeModal: () => void
setHideDefaultBranchWorkspace: (value: boolean) => void
}): Promise<void> {
await markOnboardingProjectAdded('addedRepo')
closeModal()
await openProjectDefaultCheckout({ repoId, source, setHideDefaultBranchWorkspace })
await openProjectDefaultCheckout({
repoId,
source,
selectedPath,
setHideDefaultBranchWorkspace
})
}
@@ -1016,7 +1016,8 @@ describe('createFilePathLinkProvider range bounds', () => {
['/repo/package.json', true],
['/repo/Folder With Space/content.js', true],
['/repo/My Folder', true]
])
]),
depsOverrides: Partial<Parameters<typeof createFilePathLinkProvider>[1]> = {}
) {
const pane = makePane(rows)
const managerRef = {
@@ -1031,7 +1032,8 @@ describe('createFilePathLinkProvider range bounds', () => {
startupCwd: '/repo',
managerRef,
linkProviderDisposablesRef: { current: new Map<number, IDisposable>() },
pathExistsCache
pathExistsCache,
...depsOverrides
},
linkTooltip,
getTerminalFileOpenHint()
@@ -1556,6 +1558,23 @@ describe('createFilePathLinkProvider range bounds', () => {
expect(links.map((link) => link.text)).toEqual(['/repo/My Folder'])
})
it('uses the pane-specific cwd instead of a stale lifecycle startup cwd', async () => {
vi.mocked(window.api.shell.pathExists).mockImplementation(async (pathValue) => {
return pathValue === '/repo/package.json'
})
const { provider } = createProviderSetup([makeBufferLine('package.json')], new Map(), {
startupCwd: '/repo/packages/web',
getPaneLinkCwd: () => '/repo'
})
const links = await new Promise<ILink[]>((resolve) => {
provider.provideLinks(1, (provided) => resolve(provided ?? []))
})
expect(links.map((link) => link.text)).toEqual(['package.json'])
expect(window.api.shell.pathExists).toHaveBeenCalledWith('/repo/package.json')
})
it('opens an existing extensionless spaced prefix from direct fallback cache', async () => {
setPlatform('Macintosh')
const line = 'see /repo/My Folder now'
@@ -46,6 +46,7 @@ export type LinkHandlerDeps = {
worktreeId: string
worktreePath: string
startupCwd: string
getPaneLinkCwd?: (paneId: number) => string | null
managerRef: React.RefObject<PaneManager | null>
linkProviderDisposablesRef: React.RefObject<Map<number, IDisposable>>
pathExistsCache: Map<string, boolean>
@@ -123,8 +124,9 @@ export function createFilePathLinkProvider(
logicalLines.flatMap((logicalLine) =>
extractTerminalFileLinkCandidates(logicalLine.text).map(
async (parsed): Promise<ProvidedFileLink | null> => {
const resolved = startupCwd
? resolveTerminalFileLink(parsed, startupCwd, deps.terminalHomePath)
const paneLinkCwd = deps.getPaneLinkCwd?.(paneId) ?? startupCwd
const resolved = paneLinkCwd
? resolveTerminalFileLink(parsed, paneLinkCwd, deps.terminalHomePath)
: null
if (!resolved) {
return null
@@ -289,7 +291,7 @@ export function installFilePathLinkClickFallback(
position,
terminal.cols,
{
startupCwd: deps.startupCwd,
startupCwd: deps.getPaneLinkCwd?.(paneId) ?? deps.startupCwd,
terminalHomePath: deps.terminalHomePath,
worktreeId: deps.worktreeId,
worktreePath: deps.worktreePath,
@@ -1,7 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyTerminalScrollbackRowsToMountedPanes,
clearQueuedInitialCwdAfterFirstPane,
mapRestoredPaneTitlesByPaneId,
resolvePaneLinkCwd,
resolvePaneSeedCwd,
resolveQueuedInitialCwd,
scheduleVisibilityReconcilePass,
shouldDetachPaneTransportOnUnmount,
splitPaneWithOneShotStartup,
@@ -193,6 +197,76 @@ describe('mapRestoredPaneTitlesByPaneId', () => {
})
})
describe('resolveQueuedInitialCwd', () => {
it('consumes the queued initial cwd once when the ref is unset', () => {
const consumeTabInitialCwd = vi.fn(() => '/repo/packages/web')
expect(resolveQueuedInitialCwd(undefined, consumeTabInitialCwd, '/repo')).toEqual({
queuedInitialCwd: '/repo/packages/web',
startupCwd: '/repo/packages/web'
})
expect(consumeTabInitialCwd).toHaveBeenCalledTimes(1)
})
it('reuses the existing queued state without re-reading the store', () => {
const consumeTabInitialCwd = vi.fn(() => '/repo/packages/web')
expect(resolveQueuedInitialCwd(null, consumeTabInitialCwd, '/repo')).toEqual({
queuedInitialCwd: null,
startupCwd: '/repo'
})
expect(resolveQueuedInitialCwd('/repo/packages/web', consumeTabInitialCwd, '/repo')).toEqual({
queuedInitialCwd: '/repo/packages/web',
startupCwd: '/repo/packages/web'
})
expect(consumeTabInitialCwd).not.toHaveBeenCalled()
})
})
describe('clearQueuedInitialCwdAfterFirstPane', () => {
it('clears the one-shot cwd and restores the default cwd after the first pane', () => {
expect(
clearQueuedInitialCwdAfterFirstPane('/repo/packages/web', '/repo', '/repo/packages/web')
).toEqual({
queuedInitialCwd: null,
ptyCwd: '/repo'
})
})
it('leaves the cwd unchanged when no one-shot override is queued', () => {
expect(clearQueuedInitialCwdAfterFirstPane(null, '/repo', '/repo')).toEqual({
queuedInitialCwd: null,
ptyCwd: '/repo'
})
})
})
describe('resolvePaneLinkCwd', () => {
it('prefers the pane-specific cwd when one has been seeded or confirmed', () => {
expect(
resolvePaneLinkCwd(
new Map([[2, { cwd: '/repo/packages/web', confirmed: false }]]),
2,
'/repo'
)
).toBe('/repo/packages/web')
})
it('falls back to the lifecycle startup cwd when the pane has no cached cwd yet', () => {
expect(resolvePaneLinkCwd(new Map(), 2, '/repo')).toBe('/repo')
})
})
describe('resolvePaneSeedCwd', () => {
it('prefers the inherited split cwd before OSC 7 confirms the pane cwd', () => {
expect(resolvePaneSeedCwd('/repo/packages/web', '/repo')).toBe('/repo/packages/web')
})
it('falls back to the lifecycle cwd when the pane has no split override', () => {
expect(resolvePaneSeedCwd(undefined, '/repo')).toBe('/repo')
})
})
describe('suppressIntentionalPaneCloseExit', () => {
it('suppresses the pane PTY exit before intentional close teardown destroys the transport', () => {
const suppressPtyExit = vi.fn()
@@ -318,6 +318,42 @@ function hydrateTerminalScrollbackRefs(layout: TerminalLayoutSnapshot): {
: { layout, hydrated }
}
export function resolveQueuedInitialCwd(
queuedInitialCwd: string | null | undefined,
consumeTabInitialCwd: () => string | null,
defaultTabCwd: string
): { queuedInitialCwd: string | null; startupCwd: string } {
const nextQueuedInitialCwd =
queuedInitialCwd === undefined ? consumeTabInitialCwd() : queuedInitialCwd
return {
queuedInitialCwd: nextQueuedInitialCwd,
startupCwd: nextQueuedInitialCwd ?? defaultTabCwd
}
}
export function clearQueuedInitialCwdAfterFirstPane(
queuedInitialCwd: string | null | undefined,
defaultTabCwd: string,
currentPtyCwd: string
): { queuedInitialCwd: string | null | undefined; ptyCwd: string } {
if (!queuedInitialCwd) {
return { queuedInitialCwd, ptyCwd: currentPtyCwd }
}
return { queuedInitialCwd: null, ptyCwd: defaultTabCwd }
}
export function resolvePaneLinkCwd(
paneCwdMap: PaneCwdMap,
paneId: number,
fallbackCwd: string
): string {
return paneCwdMap.get(paneId)?.cwd ?? fallbackCwd
}
export function resolvePaneSeedCwd(splitPaneCwd: string | undefined, fallbackCwd: string): string {
return splitPaneCwd ?? fallbackCwd
}
type SplitStartupPayload = { command: string; env?: Record<string, string> }
type SplitWithStartupDeps = {
@@ -474,6 +510,7 @@ export function useTerminalPaneLifecycle({
const mouseHideDisposablesRef = useRef(new Map<number, IDisposable>())
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
const imePunctuationForwarderDisposablesRef = useRef(new Map<number, IDisposable>())
const queuedInitialCwdRef = useRef<string | null | undefined>(undefined)
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@@ -548,8 +585,17 @@ export function useTerminalPaneLifecycle({
.find((candidate) => candidate.id === worktreeId)?.path ??
cwd ??
''
const startupCwd = cwd ?? worktreePath
const defaultTabCwd = cwd ?? worktreePath
const initialCwdResolution = resolveQueuedInitialCwd(
queuedInitialCwdRef.current,
() => useAppStore.getState().consumeTabInitialCwd(tabId),
defaultTabCwd
)
queuedInitialCwdRef.current = initialCwdResolution.queuedInitialCwd
const startupCwd = initialCwdResolution.startupCwd
const terminalHomePath = resolveTerminalHomePathFromEnv(startup?.env)
const getPaneLinkCwd = (paneId: number): string =>
resolvePaneLinkCwd(paneCwdRef.current, paneId, startupCwd)
// Why: existence probes can cross SSH/runtime boundaries. This cache is
// lifecycle-scoped, so external mutations and the initial 'active' runtime
// fallback can temporarily leave stale entries.
@@ -558,6 +604,7 @@ export function useTerminalPaneLifecycle({
worktreeId,
worktreePath,
startupCwd,
getPaneLinkCwd,
terminalHomePath,
managerRef,
linkProviderDisposablesRef,
@@ -617,7 +664,7 @@ export function useTerminalPaneLifecycle({
const ptyDeps = {
tabId,
worktreeId,
cwd,
cwd: startupCwd,
startup,
paneTransportsRef,
paneMode2031Ref,
@@ -657,7 +704,7 @@ export function useTerminalPaneLifecycle({
const fileOpenLinkHint = getTerminalFileOpenHint()
const urlOpenLinkHint = getTerminalUrlOpenHint()
const osc7UncHost = extractUncHost(cwd)
const osc7UncHost = extractUncHost(startupCwd)
let releaseWebviewDragPassthrough: (() => void) | null = null
@@ -710,6 +757,12 @@ export function useTerminalPaneLifecycle({
// Return true so xterm marks the sequence handled. If a future
// consumer registers on code 7, registration order decides who sees
// each sequence.
if (!paneCwdRef.current.has(pane.id)) {
paneCwdRef.current.set(pane.id, {
cwd: resolvePaneSeedCwd(spawnHints?.cwd, ptyDeps.cwd),
confirmed: false
})
}
const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => {
const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost })
if (parsedCwd) {
@@ -917,6 +970,7 @@ export function useTerminalPaneLifecycle({
activate: (event, text) => {
handleOscLink(text, event as MouseEvent | undefined, {
...linkDeps,
startupCwd: getPaneLinkCwd(pane.id),
runtimeEnvironmentId: linkDeps.getRuntimeEnvironmentIdForPane?.(pane.id) ?? null,
requestOpenLinksInAppPreference
})
@@ -968,6 +1022,13 @@ export function useTerminalPaneLifecycle({
// sets deps.startup immediately before splitPane() and is therefore
// unaffected by this clear.
ptyDeps.startup = null
const nextInitialCwdState = clearQueuedInitialCwdAfterFirstPane(
queuedInitialCwdRef.current,
defaultTabCwd,
ptyDeps.cwd
)
queuedInitialCwdRef.current = nextInitialCwdState.queuedInitialCwd
ptyDeps.cwd = nextInitialCwdState.ptyCwd
panePtyBindings.set(pane.id, panePtyBinding)
syncPaneCount()
scheduleRuntimeGraphSync()
@@ -1221,6 +1282,7 @@ export function useTerminalPaneLifecycle({
const activePane = managerRef.current?.getActivePane()
void handleOscLink(url, event, {
...linkDeps,
startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd,
runtimeEnvironmentId: activePane
? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null)
: null,
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- Why: these activation cases share one mock store and assert ordering across startup, setup, issue commands, and default tabs. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SetupScriptLaunchMode } from '../../../shared/types'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { useAppStore } from '@/store'
function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
@@ -35,6 +35,7 @@ function createMockStore(overrides: Record<string, unknown> = {}) {
markDefaultTerminalTabsApplied: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
queueTabStartupCommand: vi.fn(),
queueTabInitialCwd: vi.fn(),
queueTabSetupSplit: vi.fn(),
queueTabIssueCommandSplit: vi.fn(),
...overrides
@@ -712,3 +713,77 @@ describe('ensureWorktreeHasInitialTerminal', () => {
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
})
})
describe('activateAndRevealWorktree', () => {
afterEach(() => {
useAppStore.setState({
activeRepoId: null,
activeWorktreeId: null,
activeView: 'terminal',
filterRepoIds: [],
isNavigatingHistory: false
})
})
it('queues a one-shot initial cwd for the primary activation-created tab', () => {
const queueTabInitialCwd = vi.fn()
useAppStore.setState({
activeRepoId: null,
activeWorktreeId: null,
activeView: 'settings',
filterRepoIds: [],
isNavigatingHistory: false,
repos: [{ id: 'repo-1', connectionId: null }],
worktreesByRepo: {
'repo-1': [
{
id: 'wt-1',
repoId: 'repo-1',
path: '/repo',
displayName: 'main',
branch: 'main',
head: 'abc',
isBare: false,
isMainWorktree: true
}
]
},
getKnownWorktreeById: (worktreeId: string) =>
worktreeId === 'wt-1'
? ({
id: 'wt-1',
repoId: 'repo-1',
path: '/repo',
displayName: 'main',
branch: 'main',
head: 'abc',
isBare: false,
isMainWorktree: true
} as never)
: null,
setActiveRepo: vi.fn(),
setActiveView: vi.fn(),
setActiveWorktree: vi.fn(),
markWorktreeVisited: vi.fn(),
recordWorktreeVisit: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
createTab: vi.fn(() => ({ id: 'tab-1' })),
setActiveTab: vi.fn(),
setTabCustomTitle: vi.fn(),
setTabColor: vi.fn(),
markDefaultTerminalTabsApplied: vi.fn(),
queueTabStartupCommand: vi.fn(),
queueTabInitialCwd,
queueTabSetupSplit: vi.fn(),
queueTabIssueCommandSplit: vi.fn(),
revealWorktreeInSidebar: vi.fn()
} as never)
const result = activateAndRevealWorktree('wt-1', {
initialCwd: '/repo/packages/web'
})
expect(result).toEqual({ primaryTabId: 'tab-1' })
expect(queueTabInitialCwd).toHaveBeenCalledWith('tab-1', '/repo/packages/web')
})
})
@@ -131,6 +131,7 @@ type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
}
@@ -276,6 +277,7 @@ export function activateAndRevealWorktree(
worktreeId: string,
opts?: {
startup?: WorktreeStartupPayload
initialCwd?: string
setup?: WorktreeSetupLaunch
defaultTabs?: WorktreeDefaultTabsLaunch
issueCommand?: IssueCommandLaunch
@@ -359,6 +361,9 @@ export function activateAndRevealWorktree(
opts?.issueCommand,
opts?.defaultTabs
)
if (primaryTabId && opts?.initialCwd) {
useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd)
}
// 5. Clear sidebar filters that would hide the target worktree
// Why: revealWorktreeInSidebar relies on the worktree card being rendered
@@ -788,7 +788,7 @@ describe('applyWebSessionTabsSnapshot', () => {
) as Partial<WebSessionTabsSyncState>
const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id
console.error("PATCH tabs", JSON.stringify(patch.tabsByWorktree?.[WT]))
console.error('PATCH tabs', JSON.stringify(patch.tabsByWorktree?.[WT]))
expect(patch.tabsByWorktree?.[WT]?.[0]?.viewMode).toBe('chat')
expect(
patch.unifiedTabsByWorktree?.[WT]?.find((tab) => tab.entityId === mirroredId)?.viewMode
@@ -538,9 +538,7 @@ function buildMirroredTerminalTabs(
// the client's own record during the optimistic echo window and adopt the host
// value only when this client has no prior tab (first reconcile / other client).
const hostViewModeSurface = surfaces.find((surface) => surface.viewMode)
const viewMode = existing
? existing.viewMode
: hostViewModeSurface?.viewMode
const viewMode = existing ? existing.viewMode : hostViewModeSurface?.viewMode
return {
tab: {
id: localTabId,
@@ -1803,6 +1803,7 @@ describe('setActiveWorktree', () => {
'canExpandPaneByTabId',
'terminalLayoutsByTabId',
'pendingStartupByTabId',
'pendingInitialCwdByTabId',
'pendingSetupSplitByTabId',
'pendingIssueCommandSplitByTabId',
'tabBarOrderByWorktree',
@@ -1843,6 +1844,9 @@ describe('setActiveWorktree', () => {
pendingStartupByTabId: {
[orphanId]: { command: 'codex' }
},
pendingInitialCwdByTabId: {
[orphanId]: '/repo/packages/web'
},
tabBarOrderByWorktree: {
[wt]: [orphanId]
},
@@ -1863,6 +1867,7 @@ describe('setActiveWorktree', () => {
expect(s.runtimePaneTitlesByTabId[orphanId]).toBeUndefined()
expect(s.terminalLayoutsByTabId[orphanId]).toBeUndefined()
expect(s.pendingStartupByTabId[orphanId]).toBeUndefined()
expect(s.pendingInitialCwdByTabId[orphanId]).toBeUndefined()
expect(s.cacheTimerByKey[`${orphanId}:seed`]).toBeUndefined()
expect(s.terminalLayoutsByTabId[replacement.id]).toEqual(makeLayout())
})
+5 -1
View File
@@ -221,7 +221,11 @@ function mirrorTabPinnedToHost(state: AppState, tabId: string, isPinned: boolean
// the host or it's lost on reconnect/restart and never reaches paired clients.
// Only the user/RPC-set action path mirrors — never the reconcile that applies a
// host value — so the echoed snapshot can't re-trigger an outbound RPC (no loop).
function mirrorTabViewModeToHost(state: AppState, tabId: string, viewMode: 'terminal' | 'chat'): void {
function mirrorTabViewModeToHost(
state: AppState,
tabId: string,
viewMode: 'terminal' | 'chat'
): void {
const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId)
// Why: only terminal tab viewMode is persisted host-side; skip the RPC for other
// types instead of a no-op round trip.
@@ -14,6 +14,7 @@ type OrphanTerminalCleanupState = Pick<
| 'canExpandPaneByTabId'
| 'terminalLayoutsByTabId'
| 'pendingStartupByTabId'
| 'pendingInitialCwdByTabId'
| 'pendingSetupSplitByTabId'
| 'pendingIssueCommandSplitByTabId'
| 'tabBarOrderByWorktree'
@@ -59,6 +60,7 @@ export function buildOrphanTerminalCleanupPatch(
| 'canExpandPaneByTabId'
| 'terminalLayoutsByTabId'
| 'pendingStartupByTabId'
| 'pendingInitialCwdByTabId'
| 'pendingSetupSplitByTabId'
| 'pendingIssueCommandSplitByTabId'
| 'tabBarOrderByWorktree'
@@ -75,6 +77,7 @@ export function buildOrphanTerminalCleanupPatch(
canExpandPaneByTabId: state.canExpandPaneByTabId,
terminalLayoutsByTabId: state.terminalLayoutsByTabId,
pendingStartupByTabId: state.pendingStartupByTabId,
pendingInitialCwdByTabId: state.pendingInitialCwdByTabId,
pendingSetupSplitByTabId: state.pendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: state.pendingIssueCommandSplitByTabId,
tabBarOrderByWorktree: state.tabBarOrderByWorktree,
@@ -93,6 +96,7 @@ export function buildOrphanTerminalCleanupPatch(
const nextCanExpandPaneByTabId = { ...state.canExpandPaneByTabId }
const nextTerminalLayoutsByTabId = { ...state.terminalLayoutsByTabId }
const nextPendingStartupByTabId = { ...state.pendingStartupByTabId }
const nextPendingInitialCwdByTabId = { ...state.pendingInitialCwdByTabId }
const nextPendingSetupSplitByTabId = { ...state.pendingSetupSplitByTabId }
const nextPendingIssueCommandSplitByTabId = { ...state.pendingIssueCommandSplitByTabId }
const nextTabBarOrderByWorktree = {
@@ -114,6 +118,7 @@ export function buildOrphanTerminalCleanupPatch(
delete nextCanExpandPaneByTabId[orphanTabId]
delete nextTerminalLayoutsByTabId[orphanTabId]
delete nextPendingStartupByTabId[orphanTabId]
delete nextPendingInitialCwdByTabId[orphanTabId]
delete nextPendingSetupSplitByTabId[orphanTabId]
delete nextPendingIssueCommandSplitByTabId[orphanTabId]
for (const key of Object.keys(nextCacheTimerByKey)) {
@@ -141,6 +146,7 @@ export function buildOrphanTerminalCleanupPatch(
canExpandPaneByTabId: nextCanExpandPaneByTabId,
terminalLayoutsByTabId: nextTerminalLayoutsByTabId,
pendingStartupByTabId: nextPendingStartupByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
tabBarOrderByWorktree: nextTabBarOrderByWorktree,
@@ -345,6 +345,7 @@ export type TerminalSlice = {
telemetry?: AgentStartedTelemetry
}
>
pendingInitialCwdByTabId: Record<string, string>
/** Queued setup-split requests — when present, TerminalPane creates the
* initial pane clean, then splits (vertical or horizontal per user setting)
* and runs the command in the new pane so the main terminal stays
@@ -500,6 +501,8 @@ export type TerminalSlice = {
telemetry?: AgentStartedTelemetry
}
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
consumeTabInitialCwd: (tabId: string) => string | null
consumeTabStartupCommand: (tabId: string) => {
command: string
delivery?: 'terminal-paste'
@@ -569,6 +572,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
pendingStartupByTabId: {},
pendingInitialCwdByTabId: {},
pendingSetupSplitByTabId: {},
pendingIssueCommandSplitByTabId: {},
tabBarOrderByWorktree: {},
@@ -1013,6 +1017,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}
const nextPendingStartupByTabId = { ...s.pendingStartupByTabId }
delete nextPendingStartupByTabId[tabId]
const nextPendingInitialCwdByTabId = { ...s.pendingInitialCwdByTabId }
delete nextPendingInitialCwdByTabId[tabId]
const nextPendingSetupSplitByTabId = { ...s.pendingSetupSplitByTabId }
delete nextPendingSetupSplitByTabId[tabId]
const nextPendingIssueCommandSplitByTabId = { ...s.pendingIssueCommandSplitByTabId }
@@ -1087,6 +1093,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
canExpandPaneByTabId: nextCanExpand,
terminalLayoutsByTabId: nextLayouts,
pendingStartupByTabId: nextPendingStartupByTabId,
pendingInitialCwdByTabId: nextPendingInitialCwdByTabId,
pendingSetupSplitByTabId: nextPendingSetupSplitByTabId,
pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId,
cacheTimerByKey: nextCacheTimer,
@@ -2422,6 +2429,28 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
}))
},
queueTabInitialCwd: (tabId, cwd) => {
set((s) => ({
pendingInitialCwdByTabId: {
...s.pendingInitialCwdByTabId,
[tabId]: cwd
}
}))
},
consumeTabInitialCwd: (tabId) => {
const pending = get().pendingInitialCwdByTabId[tabId]
if (!pending) {
return null
}
set((s) => {
const next = { ...s.pendingInitialCwdByTabId }
delete next[tabId]
return { pendingInitialCwdByTabId: next }
})
return pending
},
consumeTabStartupCommand: (tabId) => {
const pending = get().pendingStartupByTabId[tabId]
if (!pending) {