diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 67597384782..71f3cbce302 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -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) + }) }) diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index e3d12f140f8..299d7ed4fbe 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -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. */ diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index b7aa15bbc95..62860a5c691 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -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', diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index df2c13c8646..7af9cef57d9 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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(), diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 0c9cf60b68c..39bbba9a75e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -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( diff --git a/src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts b/src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts index c651cc4503f..7e4917ddc2e 100644 --- a/src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-assembler-merge-parity.test.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts index 519e7a27275..79ba3fcfdf4 100644 --- a/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-incremental-assembler.test.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx index 5b7b95c8468..56d32c5aef8 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx @@ -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 }) diff --git a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx index 12a6ae4222b..483476e9c83 100644 --- a/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/AddProjectFromFolderDialog.tsx @@ -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 }) diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts index d51337dc814..e7c3c061c27 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.test.ts @@ -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 = { diff --git a/src/renderer/src/components/sidebar/project-added-default-checkout.ts b/src/renderer/src/components/sidebar/project-added-default-checkout.ts index e522f7ce119..d8ed9fb4ff9 100644 --- a/src/renderer/src/components/sidebar/project-added-default-checkout.ts +++ b/src/renderer/src/components/sidebar/project-added-default-checkout.ts @@ -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 { 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 { await markOnboardingProjectAdded('addedRepo') closeModal() - await openProjectDefaultCheckout({ repoId, source, setHideDefaultBranchWorkspace }) + await openProjectDefaultCheckout({ + repoId, + source, + selectedPath, + setHideDefaultBranchWorkspace + }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index 417fa613bea..5cbc01a0903 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -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[1]> = {} ) { const pane = makePane(rows) const managerRef = { @@ -1031,7 +1032,8 @@ describe('createFilePathLinkProvider range bounds', () => { startupCwd: '/repo', managerRef, linkProviderDisposablesRef: { current: new Map() }, - 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((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' diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts index 6b515226810..0e111027e37 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.ts @@ -46,6 +46,7 @@ export type LinkHandlerDeps = { worktreeId: string worktreePath: string startupCwd: string + getPaneLinkCwd?: (paneId: number) => string | null managerRef: React.RefObject linkProviderDisposablesRef: React.RefObject> pathExistsCache: Map @@ -123,8 +124,9 @@ export function createFilePathLinkProvider( logicalLines.flatMap((logicalLine) => extractTerminalFileLinkCandidates(logicalLine.text).map( async (parsed): Promise => { - 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, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts index c4dccba82c2..97136f0271d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 4535a378b2c..0aa627b338e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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 } type SplitWithStartupDeps = { @@ -474,6 +510,7 @@ export function useTerminalPaneLifecycle({ const mouseHideDisposablesRef = useRef(new Map()) const imeCompositionDisposablesRef = useRef(new Map()) const imePunctuationForwarderDisposablesRef = useRef(new Map()) + const queuedInitialCwdRef = useRef(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, diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index a9b9bfac459..a1937bbe8e2 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -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 = {}) { 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') + }) +}) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index eb523f9445b..08b1568c01e 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -131,6 +131,7 @@ type WorktreeActivationStore = Partial & { tabId: string, startup: { command: string; env?: Record } ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void settings?: Pick | 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 diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 0f5abed3e0b..b31cb9a92f9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -788,7 +788,7 @@ describe('applyWebSessionTabsSnapshot', () => { ) as Partial 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 diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 8d2d14f2ab9..edb7ccfa075 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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, diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 9e70b9136c0..c27533483c6 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -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()) }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index fcc682f5037..00cf7fcc1c7 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -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. diff --git a/src/renderer/src/store/slices/terminal-orphan-helpers.ts b/src/renderer/src/store/slices/terminal-orphan-helpers.ts index 36b2069ba33..bb388fad3ae 100644 --- a/src/renderer/src/store/slices/terminal-orphan-helpers.ts +++ b/src/renderer/src/store/slices/terminal-orphan-helpers.ts @@ -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, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 56914827835..1acbf288cbe 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -345,6 +345,7 @@ export type TerminalSlice = { telemetry?: AgentStartedTelemetry } > + pendingInitialCwdByTabId: Record /** 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 canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, pendingStartupByTabId: {}, + pendingInitialCwdByTabId: {}, pendingSetupSplitByTabId: {}, pendingIssueCommandSplitByTabId: {}, tabBarOrderByWorktree: {}, @@ -1013,6 +1017,8 @@ export const createTerminalSlice: StateCreator } 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 canExpandPaneByTabId: nextCanExpand, terminalLayoutsByTabId: nextLayouts, pendingStartupByTabId: nextPendingStartupByTabId, + pendingInitialCwdByTabId: nextPendingInitialCwdByTabId, pendingSetupSplitByTabId: nextPendingSetupSplitByTabId, pendingIssueCommandSplitByTabId: nextPendingIssueCommandSplitByTabId, cacheTimerByKey: nextCacheTimer, @@ -2422,6 +2429,28 @@ export const createTerminalSlice: StateCreator })) }, + 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) {