From adcf74a9fbee2002c32d5688f650ae44320367f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:33:06 -0700 Subject: [PATCH] fix: root monorepo subfolder imports at the git repo while keeping the first terminal in the subfolder (#6336) (#6574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/git/repo-detection.test.ts | 22 +++++- src/main/git/repo.ts | 22 ++++++ src/main/ipc/repos-remote.test.ts | 42 +++++++++- src/main/ipc/repos.ts | 21 ++++- .../native-chat/NativeChatInteractiveCard.tsx | 4 +- ...native-chat-assembler-merge-parity.test.ts | 5 +- .../native-chat-incremental-assembler.test.ts | 19 ++++- .../AddProjectFromFolderDialog.test.tsx | 3 + .../sidebar/AddProjectFromFolderDialog.tsx | 1 + .../project-added-default-checkout.test.ts | 32 ++++++++ .../sidebar/project-added-default-checkout.ts | 30 +++++++- .../terminal-link-handlers.test.ts | 23 +++++- .../terminal-pane/terminal-link-handlers.ts | 8 +- .../use-terminal-pane-lifecycle.test.ts | 74 ++++++++++++++++++ .../use-terminal-pane-lifecycle.ts | 68 +++++++++++++++- .../src/lib/worktree-activation.test.ts | 77 ++++++++++++++++++- src/renderer/src/lib/worktree-activation.ts | 5 ++ .../src/runtime/web-session-tabs-sync.test.ts | 2 +- .../src/runtime/web-session-tabs-sync.ts | 4 +- .../src/store/slices/store-cascades.test.ts | 5 ++ src/renderer/src/store/slices/tabs.ts | 6 +- .../store/slices/terminal-orphan-helpers.ts | 6 ++ src/renderer/src/store/slices/terminals.ts | 29 +++++++ 23 files changed, 481 insertions(+), 27 deletions(-) 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) {