diff --git a/desktop/src/components/chat/StreamingIndicator.tsx b/desktop/src/components/chat/StreamingIndicator.tsx index a6a405ab..fdf632ac 100644 --- a/desktop/src/components/chat/StreamingIndicator.tsx +++ b/desktop/src/components/chat/StreamingIndicator.tsx @@ -1,5 +1,6 @@ import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' +import { useTranslation, type TranslationKey } from '../../i18n' function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s` @@ -8,7 +9,17 @@ function formatElapsed(seconds: number): string { return `${m}m ${s}s` } +function translateServerVerb( + t: (key: TranslationKey) => string, + verb: string, +): string { + const key = `serverVerb.${verb}` as TranslationKey + const translated = t(key) + return translated === key ? verb : translated +} + export function StreamingIndicator() { + const t = useTranslation() const activeTabId = useTabStore((s) => s.activeTabId) const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined) const chatState = sessionState?.chatState ?? 'idle' @@ -17,9 +28,13 @@ export function StreamingIndicator() { const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } let verb: string if (statusVerb) { - verb = statusVerb + verb = translateServerVerb(t, statusVerb) } else { - verb = chatState === 'thinking' ? 'Thinking' : chatState === 'tool_executing' ? 'Running' : 'Working' + verb = chatState === 'thinking' + ? t('serverVerb.Thinking') + : chatState === 'tool_executing' + ? t('serverVerb.Running') + : t('serverVerb.Working') } return ( diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx index 7e40fde3..71c4151f 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -234,18 +234,6 @@ export function RepositoryLaunchControls({ return null }, [context, selectedBranch, t, useWorktree]) - const requiresIsolation = useMemo(() => { - if (context?.state !== 'ok' || !selectedBranch) return false - if (selectedBranch.name === context.currentBranch) return false - return context.dirty || selectedBranch.checkedOut - }, [context, selectedBranch]) - - useEffect(() => { - if (requiresIsolation && !useWorktree) { - onUseWorktreeChange(true) - } - }, [onUseWorktreeChange, requiresIsolation, useWorktree]) - const selectBranch = (candidate: RepositoryBranchInfo) => { onBranchChange(candidate.name) setBranchMenuOpen(false) @@ -253,7 +241,6 @@ export function RepositoryLaunchControls({ } const selectWorktreeMode = (enabled: boolean) => { - if (!enabled && requiresIsolation) return onUseWorktreeChange(enabled) setWorktreeMenuOpen(false) } @@ -481,8 +468,6 @@ export function RepositoryLaunchControls({ type="button" role="option" aria-selected={!useWorktree} - aria-disabled={requiresIsolation} - disabled={requiresIsolation} onClick={() => selectWorktreeMode(false)} className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${ !useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 4dc942b2..5db0007d 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -707,8 +707,8 @@ export const en = { 'repoLaunch.worktreeIsolated': 'Isolated worktree', 'repoLaunch.selectWorktree': 'Select worktree mode', 'repoLaunch.missingWorkdir': 'Working directory is missing.', - 'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.', - 'repoLaunch.checkedOutWarning': 'This branch is already checked out elsewhere. Enable isolated worktree or choose another branch.', + 'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching may be blocked; use isolated worktree to continue without touching this folder.', + 'repoLaunch.checkedOutWarning': 'Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.', // ─── Chat Input ────────────────────────────────────── 'chat.placeholder': 'Ask Claude to edit, debug or explain...', @@ -1190,6 +1190,9 @@ export const en = { // ─── Server Status Verbs ────────────────────────────────────── 'serverVerb.Thinking': 'Thinking', + 'serverVerb.Running': 'Running', + 'serverVerb.Working': 'Working', + 'serverVerb.Creating worktree': 'Creating worktree', 'serverVerb.Task started': 'Task started', 'serverVerb.Task in progress': 'Task in progress', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 0c8e205b..cea5abaf 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -709,8 +709,8 @@ export const zh: Record = { 'repoLaunch.worktreeIsolated': '独立工作树', 'repoLaunch.selectWorktree': '选择工作树模式', 'repoLaunch.missingWorkdir': '工作目录不存在。', - 'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。', - 'repoLaunch.checkedOutWarning': '这个分支已在其他工作树中检出。请开启独立工作树或选择其他分支。', + 'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换可能会被阻止;使用独立工作树可以继续,且不会改动当前目录。', + 'repoLaunch.checkedOutWarning': '选中的分支已在其他工作树中检出。直接启动可能会被 Git 阻止;使用“独立工作树”可以避免切换当前目录。', // ─── Chat Input ────────────────────────────────────── 'chat.placeholder': '让 Claude 编辑、调试或解释代码...', @@ -1192,6 +1192,9 @@ export const zh: Record = { // ─── Server Status Verbs ────────────────────────────────────── 'serverVerb.Thinking': '思考中', + 'serverVerb.Running': '运行中', + 'serverVerb.Working': '处理中', + 'serverVerb.Creating worktree': '正在创建工作树', 'serverVerb.Task started': '任务已启动', 'serverVerb.Task in progress': '任务进行中', diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index 759ca6c2..70326f93 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -418,7 +418,7 @@ describe('EmptySession', () => { expect(branchButton.querySelector('span')?.className).toContain('truncate') }) - it('defaults to isolated worktree when the fallback branch is checked out elsewhere', async () => { + it('keeps current worktree selectable when the fallback branch is checked out elsewhere', async () => { mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({ currentBranch: null, defaultBranch: 'main', @@ -455,18 +455,20 @@ describe('EmptySession', () => { await waitFor(() => { expect(screen.getByText('main')).toBeInTheDocument() - expect(screen.getByText('Isolated worktree')).toBeInTheDocument() + expect(screen.getByText('Current worktree')).toBeInTheDocument() }) - fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Isolated worktree/ })) - expect(await screen.findByRole('option', { name: 'Current worktree' })).toBeDisabled() + expect(screen.getByText('Selected branch is already checked out in another worktree. Direct launch may be blocked by Git; use "Isolated worktree" to avoid changing directories.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Select worktree mode: Current worktree/ })) + expect(await screen.findByRole('option', { name: 'Current worktree' })).not.toBeDisabled() fireEvent.click(screen.getByRole('button', { name: /Run/i })) await waitFor(() => { expect(mocks.createSession).toHaveBeenCalledWith({ workDir: '/workspace/project', - repository: { branch: 'main', worktree: true }, + repository: { branch: 'main', worktree: false }, }) }) }) diff --git a/src/main.tsx b/src/main.tsx index 29f23b3f..799d4dca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1147,6 +1147,9 @@ async function run(): Promise { const worktreeOption = isWorktreeModeEnabled() ? (options as { worktree?: boolean | string; }).worktree : undefined; + const worktreeBaseRef = isWorktreeModeEnabled() ? (options as { + worktreeBaseRef?: string; + }).worktreeBaseRef : undefined; let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; const worktreeEnabled = worktreeOption !== undefined; @@ -1940,7 +1943,7 @@ async function run(): Promise { const { setup } = await import('./setup.js'); - await setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, messagingSocketPath); + await setup(preSetupCwd, permissionMode, allowDangerouslySkipPermissions, worktreeEnabled, worktreeName, tmuxEnabled, sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, worktreeBaseRef, messagingSocketPath); })(); const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd); const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd); @@ -3863,6 +3866,7 @@ async function run(): Promise { // Worktree flags program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)'); + program.addOption(new Option('--worktree-base-ref ', 'Create --worktree from this git ref instead of the default branch').hideHelp()); program.option('--tmux', 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.'); if (canUserConfigureAdvisor()) { program.addOption(new Option('--advisor ', 'Enable the server-side advisor tool with the specified model (alias or full ID).').hideHelp()); diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 38390642..265c125f 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -348,4 +348,27 @@ describe('ConversationService', () => { expect(args).toContain('--effort') expect(args).toContain('max') }) + + test('buildSessionCliArgs starts pending desktop worktrees through the native CLI flag', () => { + const service = new ConversationService() as any + const args = service.buildSessionCliArgs( + '123e4567-e89b-12d3-a456-426614174000', + 'ws://127.0.0.1:3456/sdk/test-session?token=test-token', + false, + undefined, + { + requestedWorkDir: '/tmp/source-repo', + repoRoot: '/tmp/source-repo', + branch: 'feature/rail', + worktree: true, + baseRef: 'feature/rail', + worktreeSlug: 'desktop-feature-rail-123e4567', + }, + ) as string[] + + expect(args).toContain('--worktree') + expect(args).toContain('desktop-feature-rail-123e4567') + expect(args).toContain('--worktree-base-ref') + expect(args).toContain('feature/rail') + }) }) diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 03771882..9187cfb9 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -9,9 +9,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' +import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js' -import { SessionService } from '../services/sessionService.js' +import { SessionService, sessionService } from '../services/sessionService.js' import { ProviderService } from '../services/providerService.js' async function rmWithRetry(targetPath: string): Promise { @@ -456,6 +457,36 @@ describe('WebSocket Chat Integration', () => { let wsUrl: string let tmpDir: string + function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + }) + } + + async function createCleanGitRepo(): Promise { + const workDir = path.join( + tmpDir, + `repo-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) + + await fs.mkdir(workDir, { recursive: true }) + git(workDir, 'init') + git(workDir, 'config', 'user.email', 'conversations@example.com') + git(workDir, 'config', 'user.name', 'Conversations Test') + git(workDir, 'checkout', '-b', 'main') + await fs.writeFile(path.join(workDir, 'README.md'), 'main\n') + git(workDir, 'add', 'README.md') + git(workDir, 'commit', '-m', 'initial') + git(workDir, 'checkout', '-b', 'feature/rail') + await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n') + git(workDir, 'add', 'feature.txt') + git(workDir, 'commit', '-m', 'feature') + git(workDir, 'checkout', 'main') + + return workDir + } + async function withMockInitMode( mode: string | undefined, callback: () => Promise, @@ -817,6 +848,37 @@ describe('WebSocket Chat Integration', () => { expect(statusMsgs[0].state).toBe('thinking') }) + it('emits a worktree startup status before launching a repository session', async () => { + const repoDir = await createCleanGitRepo() + const { sessionId } = await sessionService.createSession(repoDir, { + branch: 'feature/rail', + worktree: true, + }) + + const messages = await runTurn(sessionId, 'Hello from repository launch test') + const statusVerbs = messages + .filter((msg) => msg.type === 'status') + .map((msg) => msg.verb) + + expect(statusVerbs).toContain('Creating worktree') + }) + + it('keeps the default startup status for current-worktree repository sessions', async () => { + const repoDir = await createCleanGitRepo() + const { sessionId } = await sessionService.createSession(repoDir, { + branch: 'main', + worktree: false, + }) + + const messages = await runTurn(sessionId, 'Hello from current worktree launch test') + const statusVerbs = messages + .filter((msg) => msg.type === 'status') + .map((msg) => msg.verb) + + expect(statusVerbs).toContain('Thinking') + expect(statusVerbs).not.toContain('Creating worktree') + }) + it('emits the derived session title before the first response completes', async () => { const sessionId = `title-fast-${crypto.randomUUID()}` const messages: any[] = [] diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index a39eef73..9d045841 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -9,7 +9,10 @@ import * as path from 'node:path' import * as os from 'node:os' import { SessionService } from '../services/sessionService.js' import { sessionService } from '../services/sessionService.js' -import { getRepositoryContext } from '../services/repositoryLaunchService.js' +import { + getRepositoryContext, + prepareSessionWorkspace, +} from '../services/repositoryLaunchService.js' import { conversationService } from '../services/conversationService.js' import { clearCommandsCache } from '../../commands.js' import { sanitizePath } from '../../utils/sessionStoragePortable.js' @@ -846,6 +849,60 @@ describe('SessionService', () => { expect(workDir).toBe('/tmp/from-meta') }) + it('should recover workDir from the latest session-meta entry', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeSessionFile('-tmp-project', sessionId, [ + makeSnapshotEntry(), + makeSessionMetaEntry('/tmp/old-worktree'), + makeUserEntry('Hello'), + makeSessionMetaEntry('/tmp/latest-worktree'), + ]) + + const workDir = await service.getSessionWorkDir(sessionId) + expect(workDir).toBe('/tmp/latest-worktree') + }) + + it('should preserve repository metadata when replacing placeholder transcripts', async () => { + const workDir = await createCleanGitRepo(tmpDir) + const { sessionId, workDir: sessionWorkDir } = await service.createSession( + workDir, + { branch: 'feature/rail', worktree: true }, + ) + + await service.clearSessionTranscript(sessionId, sessionWorkDir) + const launchInfo = await service.getSessionLaunchInfo(sessionId) + + expect(launchInfo?.workDir).toBe(sessionWorkDir) + expect(launchInfo?.repository).toMatchObject({ + requestedWorkDir: await fs.realpath(workDir), + worktree: true, + worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')), + }) + }) + + it('should remove stale placeholder files after native CLI worktree startup', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + const sourceFile = await writeSessionFile('-tmp-source', sessionId, [ + makeSnapshotEntry(), + { type: 'session-meta', isMeta: true, workDir: '/tmp/source', timestamp: '2026-01-01T00:00:00.000Z' }, + { type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:02.000Z' }, + ]) + const worktreeFile = await writeSessionFile('-tmp-source--claude-worktrees-desktop-agent', sessionId, [ + makeSnapshotEntry(), + { type: 'session-meta', isMeta: true, workDir: '/tmp/source/.claude/worktrees/desktop-agent', timestamp: '2026-01-01T00:00:01.000Z' }, + makeUserEntry('Hello from worktree'), + ]) + + const removed = await service.deletePlaceholderSessionFiles( + sessionId, + '/tmp/source/.claude/worktrees/desktop-agent', + ) + + expect(removed).toBe(1) + await expect(fs.access(sourceFile)).rejects.toThrow() + await expect(fs.access(worktreeFile)).resolves.toBeNull() + }) + it('should recover workDir from transcript cwd when session-meta is missing', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ @@ -885,41 +942,43 @@ describe('SessionService', () => { expect(entry.type).toBe('file-history-snapshot') }) - it('should create a session in an isolated worktree from the selected branch', async () => { + it('should defer isolated worktree creation until CLI startup', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( workDir, { branch: 'feature/rail', worktree: true }, ) - expect(sessionWorkDir).toContain(path.join('.claude', 'worktrees', 'desktop-feature-rail-')) - expect(git(sessionWorkDir, 'branch', '--show-current')).toStartWith('worktree-desktop-feature-rail-') - expect(await fs.readFile(path.join(sessionWorkDir, 'feature.txt'), 'utf-8')).toBe('feature\n') + expect(sessionWorkDir).toBe(await fs.realpath(workDir)) + expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(git(workDir, 'status', '--porcelain')).toBe('') - expect(await fs.readFile(path.join(workDir, '.git', 'info', 'exclude'), 'utf-8')) - .toContain('.claude/worktrees/') - const sanitized = sanitizePath(await fs.realpath(sessionWorkDir)) + const sanitized = sanitizePath(await fs.realpath(workDir)) const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n') const metadata = JSON.parse(lines[1]!) - expect(metadata.workDir).toBe(sessionWorkDir) + const plannedWorktreePath = metadata.repository.worktreePath as string + expect(metadata.workDir).toBe(await fs.realpath(workDir)) expect(metadata.repository).toMatchObject({ requestedWorkDir: await fs.realpath(workDir), branch: 'feature/rail', worktree: true, - worktreePath: sessionWorkDir, + baseRef: 'feature/rail', + worktreePath: expect.stringContaining(path.join('.claude', 'worktrees', 'desktop-feature-rail-')), + worktreeBranch: expect.stringContaining('worktree-desktop-feature-rail-'), + worktreeSlug: expect.stringContaining('desktop-feature-rail-'), }) + await expect(fs.access(plannedWorktreePath)).rejects.toThrow() const context = await getRepositoryContext(workDir) expect(context.state).toBe('ok') expect(context.branches.map((branch) => branch.name)).not.toContain( - path.basename(sessionWorkDir).replace(/^desktop-/, 'worktree-desktop-'), + path.basename(plannedWorktreePath).replace(/^desktop-/, 'worktree-desktop-'), ) expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) - it('should switch a clean checkout to the selected branch when worktree isolation is disabled', async () => { + it('should defer direct branch switching until CLI startup when worktree isolation is disabled', async () => { const workDir = await createCleanGitRepo(tmpDir) const { sessionId, workDir: sessionWorkDir } = await service.createSession( workDir, @@ -927,8 +986,7 @@ describe('SessionService', () => { ) expect(sessionWorkDir).toBe(await fs.realpath(workDir)) - expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n') - expect(await fs.readFile(path.join(workDir, 'feature.txt'), 'utf-8')).toBe('feature\n') + expect(git(workDir, 'branch', '--show-current')).toBe('main\n') const sanitized = sanitizePath(await fs.realpath(workDir)) const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`) @@ -943,53 +1001,55 @@ describe('SessionService', () => { }) }) - it('should not select hidden desktop worktree branches when no branch is requested', async () => { + it('should not list hidden desktop worktree branches', async () => { const workDir = await createCleanGitRepo(tmpDir) - const { workDir: firstWorktreeDir } = await service.createSession( - workDir, - { branch: 'feature/rail', worktree: true }, - ) + const existingWorktree = path.join(tmpDir, `desktop-hidden-${Date.now()}`) + git(workDir, 'worktree', 'add', '-b', 'worktree-desktop-hidden', existingWorktree, 'feature/rail') - const { workDir: nestedWorktreeDir } = await service.createSession( - firstWorktreeDir, - { worktree: true }, - ) + expect(git(existingWorktree, 'branch', '--show-current')).toBe('worktree-desktop-hidden\n') - expect(nestedWorktreeDir).toContain(path.join('.claude', 'worktrees', 'desktop-')) - expect(git(nestedWorktreeDir, 'branch', '--show-current')).toStartWith('worktree-desktop-') - - const context = await getRepositoryContext(firstWorktreeDir) + const context = await getRepositoryContext(existingWorktree) expect(context.state).toBe('ok') - expect(context.currentBranch).toStartWith('worktree-desktop-') + expect(context.currentBranch).toBe('worktree-desktop-hidden') expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false) expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false) }) - it('should reject direct branch launch from a dirty working tree with a stable error code', async () => { + it('should defer dirty direct branch launch validation until CLI startup', async () => { const workDir = await createCleanGitRepo(tmpDir) await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n') - await expect(service.createSession( + const { sessionId } = await service.createSession( workDir, { branch: 'feature/rail', worktree: false }, - )).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' }) + ) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8')) .toContain('local-pricing-edit') + await expect(prepareSessionWorkspace( + workDir, + { branch: 'feature/rail', worktree: false }, + sessionId, + )).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' }) }) - it('should reject direct branch launch when the branch is checked out elsewhere', async () => { + it('should defer checked-out direct branch launch validation until CLI startup', async () => { const workDir = await createCleanGitRepo(tmpDir) const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`) git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail') - await expect(service.createSession( + const { sessionId } = await service.createSession( workDir, { branch: 'feature/rail', worktree: false }, - )).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' }) + ) expect(git(workDir, 'branch', '--show-current')).toBe('main\n') + await expect(prepareSessionWorkspace( + workDir, + { branch: 'feature/rail', worktree: false }, + sessionId, + )).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' }) }) it('should reject branch launch outside Git repositories with a stable error code', async () => { @@ -1270,7 +1330,7 @@ describe('Sessions API', () => { expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true) }) - it('GET /api/sessions/recent-projects should hide internal desktop worktree branch metadata', async () => { + it('GET /api/sessions/recent-projects should keep pending repository launches on the source project', async () => { const workDir = await createCleanGitRepo(tmpDir) const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -1292,8 +1352,8 @@ describe('Sessions API', () => { const project = body.projects.find((candidate) => candidate.realPath === created.workDir) expect(project).toBeDefined() expect(project?.projectName).toBe(path.basename(workDir)) - expect(project?.branch).toBeNull() - expect(project?.realPath).toContain('/.claude/worktrees/') + expect(project?.branch).toBe('main') + expect(project?.realPath).toBe(await fs.realpath(workDir)) }) it('GET /api/sessions/:id should return session detail', async () => { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index d94296e3..97c1ab43 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -12,6 +12,10 @@ import * as path from 'node:path' import { ProviderService } from './providerService.js' import { sessionService } from './sessionService.js' import { diagnosticsService } from './diagnosticsService.js' +import { + prepareSessionWorkspace, + type PreparedSessionWorkspace, +} from './repositoryLaunchService.js' import { buildClaudeCliArgs, resolveClaudeCliLauncher, @@ -90,8 +94,19 @@ export class ConversationService { sdkUrl: string, shouldResume: boolean, options?: SessionStartOptions, + repository?: PreparedSessionWorkspace['repository'], ): string[] { const dangerousMode = process.env.CLAUDE_DANGEROUS_MODE === '1' + const worktreeArgs = + !shouldResume && repository?.worktree + ? [ + '--worktree', + repository.worktreeSlug || repository.worktreeBranch || repository.branch, + '--worktree-base-ref', + repository.baseRef, + ] + : [] + return this.resolveCliArgs([ '--print', '--verbose', @@ -106,6 +121,7 @@ export class ConversationService { // server only sees the completed assistant message at turn end. '--include-partial-messages', ...(shouldResume ? ['--resume', sessionId] : ['--session-id', sessionId]), + ...worktreeArgs, '--replay-user-messages', ...this.getRuntimeArgs(options), ...this.getPermissionArgs(options?.permissionMode, dangerousMode), @@ -149,15 +165,40 @@ export class ConversationService { await sessionService.clearSessionTranscript(sessionId, workDir) } + let launchWorkDir = workDir + let launchRepository = launchInfo?.repository + if (!shouldResume && launchRepository?.worktree) { + launchWorkDir = launchRepository.requestedWorkDir || launchRepository.repoRoot || workDir + } else if (!shouldResume && launchRepository) { + const preparedWorkspace = await prepareSessionWorkspace( + workDir, + { + branch: launchRepository.branch, + worktree: false, + }, + sessionId, + ) + launchWorkDir = preparedWorkspace.workDir + launchRepository = preparedWorkspace.repository + } + + if (!fs.existsSync(launchWorkDir) || !fs.statSync(launchWorkDir).isDirectory()) { + throw new ConversationStartupError( + `Working directory does not exist or is not a directory: ${launchWorkDir}`, + 'WORKDIR_INVALID', + ) + } + const args = this.buildSessionCliArgs( sessionId, sdkUrl, shouldResume, options, + launchRepository, ) console.log( - `[ConversationService] Starting CLI for ${sessionId}, cwd: ${workDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`, + `[ConversationService] Starting CLI for ${sessionId}, cwd: ${launchWorkDir} (process.cwd()=${process.cwd()}, CALLER_DIR will be pinned to workDir)`, ) // IMPORTANT (Bug#5): 必须覆盖子进程继承的 CALLER_DIR / PWD。 @@ -170,12 +211,12 @@ export class ConversationService { // 工作目录就变成 `/`。把 CALLER_DIR / PWD 显式覆盖成 workDir,preload.ts // chdir 后落到正确目录。 // - const childEnv = await this.buildChildEnv(workDir, sdkUrl, options) + const childEnv = await this.buildChildEnv(launchWorkDir, sdkUrl, options) let proc: ReturnType try { proc = Bun.spawn(args, { - cwd: workDir, + cwd: launchWorkDir, env: childEnv, stdin: 'pipe', stdout: 'pipe', @@ -196,7 +237,7 @@ export class ConversationService { }, }) throw new ConversationStartupError( - `Failed to spawn CLI in ${workDir}: ${ + `Failed to spawn CLI in ${launchWorkDir}: ${ spawnErr instanceof Error ? spawnErr.message : String(spawnErr) }`, 'CLI_SPAWN_FAILED', @@ -206,7 +247,7 @@ export class ConversationService { const session: SessionProcess = { proc, outputCallbacks: [], - workDir, + workDir: launchWorkDir, permissionMode: options?.permissionMode || 'default', sdkToken: this.getSdkTokenFromUrl(sdkUrl), sdkSocket: null, @@ -264,7 +305,7 @@ export class ConversationService { code: startupError.code, exitCode: startupExitCode, retryable: startupError.retryable, - workDir, + workDir: launchWorkDir, permissionMode: options?.permissionMode || 'default', providerId: options?.providerId ?? null, model: options?.model ?? null, @@ -279,8 +320,9 @@ export class ConversationService { if (shouldReplacePlaceholder || !launchInfo) { await sessionService.appendSessionMetadata(sessionId, { - workDir, + workDir: launchWorkDir, customTitle: launchInfo?.customTitle ?? null, + repository: launchRepository, }) } diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index c8953ee6..6f4b9d2f 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -78,6 +78,7 @@ export type PreparedSessionWorkspace = { baseRef: string worktreePath?: string worktreeBranch?: string + worktreeSlug?: string } } @@ -421,6 +422,38 @@ async function createDesktopWorktree( baseRef: branch.baseRef, worktreePath, worktreeBranch: branchName, + worktreeSlug: slug, + }, + } +} + +function planIsolatedWorktree( + context: RepositoryContextResult, + branch: ResolvedBranch, + sessionId: string, +): PreparedSessionWorkspace { + if (context.state !== 'ok' || !context.repoRoot) { + throw repositoryBadRequest( + REPOSITORY_ERROR.notGit, + 'Cannot create a worktree outside a Git repository', + ) + } + + const slug = safeWorktreeSlug(branch.name, sessionId) + const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug) + const branchName = worktreeBranchName(slug) + + return { + workDir: context.workDir, + repository: { + requestedWorkDir: context.workDir, + repoRoot: context.repoRoot, + branch: branch.name, + worktree: true, + baseRef: branch.baseRef, + worktreePath, + worktreeBranch: branchName, + worktreeSlug: slug, }, } } @@ -529,3 +562,56 @@ export async function prepareSessionWorkspace( ? createDesktopWorktree(context, branch, sessionId) : switchExistingCheckout(context, branch) } + +export async function resolveSessionWorkspaceLaunch( + workDir: string, + options: CreateSessionRepositoryOptions | undefined, + sessionId: string, +): Promise { + const absWorkDir = await resolveDirectory(workDir) + + if (!options?.branch && !options?.worktree) { + return { workDir: absWorkDir } + } + + const context = await getRepositoryContext(absWorkDir) + if (context.state !== 'ok') { + if (context.state === 'not_git_repo') { + throw repositoryBadRequest( + REPOSITORY_ERROR.notGit, + 'Selected directory is not a Git repository', + ) + } + if (context.state === 'missing_workdir') { + throw repositoryBadRequest( + REPOSITORY_ERROR.workdirMissing, + context.error || 'Working directory does not exist', + ) + } + throw repositoryBadRequest( + REPOSITORY_ERROR.contextFailed, + context.error || 'Failed to inspect Git repository', + ) + } + + const branch = resolveBranch(context, options.branch) + if (!branch) { + throw repositoryBadRequest( + REPOSITORY_ERROR.branchNotFound, + `Branch not found: ${options.branch || 'default branch'}`, + ) + } + + return options.worktree + ? planIsolatedWorktree(context, branch, sessionId) + : { + workDir: context.workDir, + repository: { + requestedWorkDir: context.workDir, + repoRoot: context.repoRoot, + branch: branch.name, + worktree: false, + baseRef: branch.baseRef, + }, + } +} diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index fffe28af..64b55095 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -20,8 +20,9 @@ import { } from '../../utils/context.js' import { getCanonicalName } from '../../utils/model/model.js' import { - prepareSessionWorkspace, + resolveSessionWorkspaceLaunch, type CreateSessionRepositoryOptions, + type PreparedSessionWorkspace, } from './repositoryLaunchService.js' // ============================================================================ @@ -47,6 +48,7 @@ export type SessionLaunchInfo = { filePath: string projectDir: string workDir: string + repository?: PreparedSessionWorkspace['repository'] transcriptMessageCount: number customTitle: string | null } @@ -253,7 +255,8 @@ export class SessionService { entries: RawEntry[], fallbackProjectDir?: string, ): string | null { - for (const entry of entries) { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i] if (entry.type === 'session-meta' && typeof (entry as Record).workDir === 'string') { return (entry as Record).workDir as string } @@ -269,6 +272,24 @@ export class SessionService { return fallbackProjectDir ? this.desanitizePath(fallbackProjectDir) : null } + private resolveRepositoryFromEntries(entries: RawEntry[]): PreparedSessionWorkspace['repository'] | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const repository = (entries[i] as Record)?.repository + if (repository && typeof repository === 'object') { + return repository as PreparedSessionWorkspace['repository'] + } + } + return undefined + } + + private countTranscriptMessages(entries: RawEntry[]): number { + return entries.filter((entry) => + !entry.isMeta && + !!entry.message?.role && + (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system') + ).length + } + // -------------------------------------------------------------------------- // Entry → MessageEntry conversion // -------------------------------------------------------------------------- @@ -1213,7 +1234,7 @@ export class SessionService { // expand relative paths — in bundled sidecar mode the server's cwd is // typically '/'. Callers (IM adapters) already send absolute realPath, // but we log here so cwd regressions are caught early. - const preparedWorkspace = await prepareSessionWorkspace( + const preparedWorkspace = await resolveSessionWorkspaceLaunch( resolvedWorkDir, repositoryOptions, sessionId, @@ -1358,26 +1379,21 @@ export class SessionService { const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || process.cwd() + const repository = this.resolveRepositoryFromEntries(entries) let customTitle: string | null = null - let transcriptMessageCount = 0 for (const entry of entries) { if (entry.type === 'custom-title' && typeof entry.customTitle === 'string') { customTitle = entry.customTitle } - if ( - !entry.isMeta && - entry.message?.role && - (entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system') - ) { - transcriptMessageCount++ - } } + const transcriptMessageCount = this.countTranscriptMessages(entries) return { filePath: found.filePath, projectDir: found.projectDir, workDir, + repository, transcriptMessageCount, customTitle, } @@ -1407,6 +1423,7 @@ export class SessionService { const entries = await this.readJsonlFile(found.filePath) const workDir = this.resolveWorkDirFromEntries(entries, found.projectDir) || fallbackWorkDir || process.cwd() + const repository = this.resolveRepositoryFromEntries(entries) const now = new Date().toISOString() const initialEntry = { @@ -1424,6 +1441,7 @@ export class SessionService { type: 'session-meta', isMeta: true, workDir, + repository, timestamp: now, } @@ -1436,15 +1454,22 @@ export class SessionService { async appendSessionMetadata( sessionId: string, - metadata: { workDir: string; customTitle?: string | null } + metadata: { + workDir: string + customTitle?: string | null + repository?: PreparedSessionWorkspace['repository'] + } ): Promise { const found = await this.findSessionFile(sessionId) if (!found) return + const entries = await this.readJsonlFile(found.filePath) + const repository = metadata.repository ?? this.resolveRepositoryFromEntries(entries) await this.appendJsonlEntry(found.filePath, { type: 'session-meta', isMeta: true, workDir: metadata.workDir, + repository, timestamp: new Date().toISOString(), }) @@ -1457,6 +1482,38 @@ export class SessionService { } } + async deletePlaceholderSessionFiles( + sessionId: string, + keepWorkDir: string, + ): Promise { + if (!this.isValidSessionId(sessionId)) return 0 + + const projectsDir = this.getProjectsDir() + let projectDirs: import('node:fs').Dirent[] + try { + projectDirs = await fs.readdir(projectsDir, { withFileTypes: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0 + throw err + } + + const keepProjectDir = this.sanitizePath(keepWorkDir) + let removed = 0 + for (const projectDir of projectDirs) { + if (!projectDir.isDirectory()) continue + if (projectDir.name === keepProjectDir) continue + const filePath = path.join(projectsDir, projectDir.name, `${sessionId}.jsonl`) + const entries = await this.readJsonlFile(filePath) + if (entries.length === 0) continue + + if (this.countTranscriptMessages(entries) > 0) continue + + await fs.rm(filePath, { force: true }) + removed += 1 + } + return removed + } + async trimSessionMessagesFrom( sessionId: string, startMessageId: string, diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 936b0461..e30a9135 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -69,6 +69,22 @@ const prewarmedSessions = new Set() const prewarmIdleTimers = new Map>() const DEFAULT_PREWARM_IDLE_TIMEOUT_MS = 5 * 60_000 +async function sendRepositoryStartupStatus( + ws: ServerWebSocket, + sessionId: string, + reason: 'user_message' | 'prewarm_session', +): Promise { + if (reason !== 'user_message') return + + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + const repository = launchInfo?.repository + if (!repository) return + + if (repository.worktree) { + sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Creating worktree' }) + } +} + export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> { return sessionSlashCommands.get(sessionId) || [] } @@ -164,7 +180,7 @@ export const handleWebSocket = { break case 'prewarm_session': - handlePrewarmSession(ws) + void handlePrewarmSession(ws) break case 'stop_generation': @@ -368,12 +384,18 @@ async function handleDesktopClearCommand( }) } -function handlePrewarmSession(ws: ServerWebSocket) { +async function handlePrewarmSession(ws: ServerWebSocket) { const { sessionId } = ws.data if (conversationService.hasSession(sessionId) || sessionStartupPromises.has(sessionId)) { return } + const launchInfo = await sessionService.getSessionLaunchInfo(sessionId).catch(() => null) + if (launchInfo?.repository) { + console.log(`[WS] Skipping prewarm for pending repository launch session ${sessionId}`) + return + } + prewarmPendingSessions.add(sessionId) void ensureCliSessionStarted(ws, sessionId, 'prewarm_session') .then(() => { @@ -752,6 +774,14 @@ function markPrewarmed(sessionId: string) { function cacheSessionInitMetadata(sessionId: string, cliMsg: any) { if (cliMsg?.type !== 'system' || cliMsg.subtype !== 'init') return + if (typeof cliMsg.cwd === 'string' && cliMsg.cwd.trim()) { + void (async () => { + await sessionService.appendSessionMetadata(sessionId, { + workDir: cliMsg.cwd, + }) + await sessionService.deletePlaceholderSessionFiles(sessionId, cliMsg.cwd) + })() + } if (cliMsg.slash_commands && Array.isArray(cliMsg.slash_commands)) { sessionSlashCommands.set(sessionId, cliMsg.slash_commands.map((cmd: any) => ({ name: typeof cmd === 'string' ? cmd : (cmd.name || cmd.command || ''), @@ -837,6 +867,7 @@ async function ensureCliSessionStarted( const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + `?token=${encodeURIComponent(crypto.randomUUID())}` + await sendRepositoryStartupStatus(ws, sessionId, reason) console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`) await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings) })() diff --git a/src/setup.ts b/src/setup.ts index 1c0c322d..5ab3122d 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -62,6 +62,7 @@ export async function setup( tmuxEnabled: boolean, customSessionId?: string | null, worktreePRNumber?: number, + worktreeBaseRef?: string, messagingSocketPath?: string, ): Promise { logForDiagnosticsNoPII('info', 'setup_started') @@ -244,7 +245,12 @@ export async function setup( getSessionId(), slug, tmuxSessionName, - worktreePRNumber ? { prNumber: worktreePRNumber } : undefined, + worktreePRNumber || worktreeBaseRef + ? { + ...(worktreePRNumber ? { prNumber: worktreePRNumber } : {}), + ...(worktreeBaseRef ? { baseRef: worktreeBaseRef } : {}), + } + : undefined, ) } catch (error) { process.stderr.write( diff --git a/src/utils/worktree.ts b/src/utils/worktree.ts index 47061cbb..bfb6a52e 100644 --- a/src/utils/worktree.ts +++ b/src/utils/worktree.ts @@ -265,7 +265,7 @@ function worktreePathFor(repoRoot: string, slug: string): string { async function getOrCreateWorktree( repoRoot: string, slug: string, - options?: { prNumber?: number }, + options?: { prNumber?: number; baseRef?: string }, ): Promise { const worktreePath = worktreePathFor(repoRoot, slug) const worktreeBranch = worktreeBranchName(slug) @@ -305,6 +305,8 @@ async function getOrCreateWorktree( ) } baseBranch = 'FETCH_HEAD' + } else if (options?.baseRef) { + baseBranch = options.baseRef } else { // If origin/ already exists locally, skip fetch. In large repos // (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph @@ -734,7 +736,7 @@ export async function createWorktreeForSession( sessionId: string, slug: string, tmuxSessionName?: string, - options?: { prNumber?: number }, + options?: { prNumber?: number; baseRef?: string }, ): Promise { // Must run before the hook branch below — hooks receive the raw slug as an // argument, and the git branch builds a path from it via path.join.