diff --git a/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts b/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts index 36542699e9b..fec20bfab3a 100644 --- a/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts +++ b/src/main/ai-vault/session-scanner-pi-omp-shared-root.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, utimes } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -109,6 +109,15 @@ async function writeSharedRootTranscripts(sessionsDir: string): Promise { runtime } ]) + // Linux mtimes tick coarsely, so back-to-back writes can tie and change which row the cap keeps. + for (const [path, lastWrite] of [ + [join(piDir, '2026-05-01T09-00-00-000Z_pi.jsonl'), '2026-05-01T09:00:01.000Z'], + [join(ompDir, `${OMP_STEM}.jsonl`), '2026-05-01T10:00:02.000Z'], + [join(ompDir, '2026-04-01T10-00-00-000Z_legacy.jsonl'), '2026-04-01T10:00:01.000Z'], + [join(ompDir, OMP_STEM, 'Worker.jsonl'), '2026-05-01T10:00:06.000Z'] + ]) { + await utimes(path, new Date(lastWrite), new Date(lastWrite)) + } } describe('scanAiVaultSessions with overlapping Pi and OMP roots', () => { diff --git a/src/main/ipc/worktrees-create-first-work-rename.test.ts b/src/main/ipc/worktrees-create-first-work-rename.test.ts new file mode 100644 index 00000000000..5ec1e71b20c --- /dev/null +++ b/src/main/ipc/worktrees-create-first-work-rename.test.ts @@ -0,0 +1,205 @@ +import { + labelOnlyCreateRequests, + renameCreatedWorktreeOnFirstWork +} from '../worktree-create-first-work-test-harness' +import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work' +import type { CreateWorktreeResult } from '../../shared/worktree/create-types' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { describeCreatedWorktreeMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' + +vi.mock('../text-generation/commit-message-text-generation', () => ({ + resolveTextGenerationParams: () => ({ + ok: true, + params: { agentId: 'claude', model: 'fixture' } + }), + generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' }) +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: async () => ({ ok: true }) +})) + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + (await importOriginal()) as Record + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty/provider/registry', async () => + (await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock() +) +vi.mock('./pty/provider/state-cleanup', async () => + (await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock() +) + +describe('desktop label-only creation through first work', () => { + function expectCreatedWorktree(result: unknown): asserts result is CreateWorktreeResult { + expect(result).toEqual( + expect.objectContaining({ + worktree: expect.objectContaining({ + id: expect.any(String), + repoId: 'repo-1', + path: expect.any(String), + head: expect.any(String), + branch: expect.stringMatching(/^refs\/heads\//), + isBare: false, + isMainWorktree: false, + displayName: expect.any(String), + displayNameMode: expect.stringMatching(/^(fixed|automatic)$/), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: expect.any(Number), + lastActivityAt: expect.any(Number) + }) + }) + ) + } + + let savedMeta: Partial + beforeEach(() => { + setupWorktreeHandlers() + savedMeta = {} + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace', + autoRenameBranchFromWork: true + }) + store.setWorktreeMeta.mockImplementation((_target, updates) => { + savedMeta = { ...savedMeta, ...updates } + return savedMeta + }) + describeCreatedWorktreeMock.mockImplementation(async (_repoPath, path, branch) => ({ + path, + branch: `refs/heads/${branch}`, + head: 'abc123', + isBare: false, + isMainWorktree: false + })) + }) + + it.each(labelOnlyCreateRequests)( + 'creates and renames a creature branch for $name while projecting its label', + async (request) => { + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + ...request + }) + expectCreatedWorktree(result) + const { worktree } = result + const branch = worktree.branch.replace(/^refs\/heads\//, '') + expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true) + expect(worktree.displayName).toBe(request.name) + expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed') + const renamed = await renameCreatedWorktreeOnFirstWork( + worktree, + savedMeta, + gitExecFileAsyncMock + ) + expect(renamed).toMatchObject({ + branch: 'refs/heads/fix-auth', + displayName: request.nameWasGenerated ? 'Fix auth' : request.name, + displayNameMode: 'fixed' + }) + } + ) + + it.each([ + { branchNameOverride: 'feature/exact', enabled: true, branch: 'feature/exact' }, + { branchNameOverride: undefined, enabled: false, branch: 'typed-label' } + ])( + 'preserves branch choice $branch (automatic=$enabled)', + async ({ branchNameOverride, enabled, branch }) => { + store.getSettings.mockReturnValue({ + branchPrefix: 'none', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + workspaceDir: '/workspace', + autoRenameBranchFromWork: enabled + }) + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'typed-label', + branchNameOverride + }) + expectCreatedWorktree(result) + const { worktree } = result + expect(worktree).toMatchObject({ + branch: `refs/heads/${branch}`, + displayName: 'typed-label', + displayNameMode: 'fixed' + }) + } + ) +}) diff --git a/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts b/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts new file mode 100644 index 00000000000..222714dc048 --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-create-pr-conflict.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' + +const { + runtimeCreateMocks: mocks, + worktreePath, + pickedBranch, + createWorktree, + resetRuntimeCreateMocks +} = await vi.hoisted(async () => await import('./runtime-local-worktree-create-test-harness')) + +vi.mock('../text-generation/commit-message-text-generation', () => ({ + resolveTextGenerationParams: () => ({ + ok: true, + params: { agentId: 'claude', model: 'fixture' } + }), + generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' }) +})) +vi.mock('../text-generation/commit-message-agent-environment', () => ({ + prepareLocalCommitMessageAgentEnv: async () => ({ ok: true }) +})) + +vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.git })) +vi.mock('../project-runtime-git-options', () => ({ + getLocalProjectGitExecOptions: () => ({ cwd: '/repo', ...mocks.routing() }), + getLocalProjectWorktreeGitOptions: mocks.routing, + getWorktreeMirrorDistro: () => undefined +})) +vi.mock('../git/repo', () => ({ + getBaseRefDefault: mocks.defaultBase, + resolveDefaultBaseRefWithLocalGit: mocks.defaultBase, + getBranchConflictKind: mocks.branchConflict +})) +vi.mock('../git/git-username', () => ({ resolveLocalGitUsername: async () => '' })) +vi.mock('../hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks })) +vi.mock('../git/worktree-base-ref-probe', () => ({ hasLocalWorktreeBaseRef: mocks.hasBase })) +vi.mock('./runtime-worktree-create-git', () => ({ + resolveCreateBranchName: mocks.branchName, + canCheckoutExistingLocalBranch: mocks.canCheckout, + getLocalGitHubPrForBranch: mocks.githubPr, + getSelectedHostedReviewForBranch: vi.fn() +})) +vi.mock('./runtime-worktree-filesystem', () => ({ runtimePathExists: mocks.pathExists })) +vi.mock('../worktree-create-preparation', () => ({ consumePreparedWorktreeCreate: mocks.consume })) +vi.mock('../git/worktree', () => ({ addWorktree: mocks.add, addSparseWorktree: mocks.addSparse })) +vi.mock('../ipc/worktree-remote', () => ({ configureCreatedWorktreePushTarget: mocks.pushTarget })) +vi.mock('../ipc/created-worktree-reconciliation', () => ({ resolveCreatedWorktree: mocks.listing })) +vi.mock('../worktree-name-retirement', () => ({ + failedWorktreeCreationNeedsRetirement: vi.fn(), + retireGeneratedWorktreeName: vi.fn() +})) +vi.mock('../git/worktree-shared-directories', () => ({ + resolveWorktreeSharedDirectories: mocks.resolveShared +})) +vi.mock('../git/worktree-include-file', () => ({ + resolveWorktreeIncludePaths: mocks.resolveInclude +})) +vi.mock('../ipc/worktree-symlinks', () => ({ + createWorktreeCopiedPaths: mocks.copyPaths, + createWorktreeLinkedPaths: vi.fn(), + createWorktreeSharedPaths: vi.fn() +})) + +beforeEach(resetRuntimeCreateMocks) + +describe('runtime create PR conflict probe', () => { + const remoteTrackingBase = { + remote: 'origin', + branch: 'main', + base: 'origin/main', + ref: 'refs/remotes/origin/main' + } + + beforeEach(() => { + mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) + }) + + it.each([1, 10])( + 'keeps the selected name and source through %i occupied paths, without PR eligibility', + async (occupied) => { + const options = { wslDistro: 'Ubuntu' } + mocks.routing.mockReturnValue(options) + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + mocks.githubPr.mockResolvedValue({ number: 42 }) + let pathChecks = 0 + mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied) + + await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } }) + + expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]]) + expect(mocks.branchName).toHaveBeenCalledOnce() + expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]]) + expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1) + expect(mocks.githubPr).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith( + '/repo', + resolve('/worktrees', `app-${occupied + 1}`), + 'main', + ['src'], + remoteTrackingBase.base, + false, + expect.objectContaining({ remoteTrackingBase, ...options }) + ) + } + ) + + it.each([true, false])( + 'keeps local eligibility with picker intent (reusable: %s)', + async (reusable) => { + mocks.branchConflict.mockImplementation(async (_path, branch: string) => + branch === 'main' ? 'local' : null + ) + mocks.canCheckout.mockImplementation( + async (_path, branch: string) => reusable && branch === 'main' + ) + mocks.pathExists.mockImplementation(async (path) => path === worktreePath) + mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) + + await createWorktree(pickedBranch) + + expect(mocks.add).toHaveBeenCalledWith( + '/repo', + resolve('/worktrees', 'app-2'), + reusable ? 'main' : 'main-2', + 'origin/main', + false, + false, + expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {}) + ) + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual( + reusable ? [] : ['main-2'] + ) + expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable) + } + ) + + it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => { + mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' }) + mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name) + mocks.branchConflict.mockImplementation(async (_path, branch: string) => + branch === 'feature/test' ? 'remote' : null + ) + + await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' }) + + expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2') + }) + + it('runs the first name PR lookup while the base fetch is still in flight', async () => { + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + let finishFetch!: () => void + mocks.refresh.mockReturnValue( + new Promise((resolve) => { + finishFetch = () => resolve({ ok: true }) + }) + ) + let fetchPendingAtLookup = false + mocks.githubPr.mockImplementation(async () => { + fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1 + finishFetch?.() + return null + }) + + await createWorktree() + + expect(fetchPendingAtLookup).toBe(true) + expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {}) + expect(mocks.consume).toHaveBeenCalledWith( + expect.objectContaining({ + branch: 'app', + options: expect.objectContaining({ remoteTrackingBase }) + }) + ) + }) + + it('suffixes past a PR on the first name', async () => { + mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2']) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' })) + }) + + it.each([undefined, 'Ubuntu'])( + 'probes the next name for a PR once the first name collides, routed for %s', + async (wslDistro) => { + const routing = wslDistro ? { wslDistro } : {} + mocks.routing.mockReturnValue(routing) + mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr).toHaveBeenCalledOnce() + expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing) + } + ) + + it('skips a later name that already has a PR', async () => { + mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null) + mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null) + + await createWorktree() + + expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3']) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' })) + }) + + it('reports the naming failure when the started base fetch also fails', async () => { + mocks.remoteBase.mockResolvedValue(remoteTrackingBase) + mocks.refresh.mockRejectedValue(new Error('offline')) + mocks.branchConflict.mockResolvedValue('local') + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect(createWorktree()).rejects.toThrow('Branch "app-') + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.refresh).toHaveBeenCalledOnce() + expect(unhandled).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandled) + } + }) + + it('runs the first name PR lookup while the origin fetch is still in flight', async () => { + mocks.hasBase.mockResolvedValue(false) + let finishFetch!: () => void + mocks.fetch.mockReturnValue( + new Promise((resolve) => { + finishFetch = resolve + }) + ) + let fetchPendingAtLookup = false + mocks.githubPr.mockImplementation(async () => { + fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1 + finishFetch?.() + return null + }) + + await createWorktree() + + expect(fetchPendingAtLookup).toBe(true) + expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {}) + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' })) + }) + + it('reports the naming failure when the started origin fetch also fails', async () => { + mocks.hasBase.mockResolvedValue(false) + mocks.fetch.mockRejectedValue(new Error('offline')) + mocks.branchConflict.mockResolvedValue('local') + const unhandled = vi.fn() + process.on('unhandledRejection', unhandled) + try { + await expect(createWorktree()).rejects.toThrow('Branch "app-') + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.fetch).toHaveBeenCalledOnce() + expect(unhandled).not.toHaveBeenCalled() + expect(mocks.consume).not.toHaveBeenCalled() + } finally { + process.off('unhandledRejection', unhandled) + } + }) +}) diff --git a/src/main/runtime/runtime-local-worktree-create-test-harness.ts b/src/main/runtime/runtime-local-worktree-create-test-harness.ts new file mode 100644 index 00000000000..c25f00ff34e --- /dev/null +++ b/src/main/runtime/runtime-local-worktree-create-test-harness.ts @@ -0,0 +1,189 @@ +import { expect, vi, type Mock } from 'vitest' +import { resolve } from 'node:path' +import type { Store } from '../persistence' +import type { WorktreeMeta } from '../../shared/worktree/meta-types' +import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' +import type { Repo } from '../../shared/repo-types' +import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' +import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor' +import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' +import type { SparsePreset } from '../../shared/worktree/create-types' + +type RuntimeCreateMocks = { + git: Mock + rearm: Mock + routing: Mock<() => { wslDistro?: string }> + defaultBase: Mock + hasBase: Mock + branchName: Mock + canCheckout: Mock + pathExists: Mock + branchConflict: Mock + githubPr: Mock + consume: Mock + add: Mock + addSparse: Mock + pushTarget: Mock + listing: Mock + remoteBase: Mock + hasRemoteRef: Mock + refresh: Mock + fetch: Mock + resolveShared: Mock<() => Promise> + resolveInclude: Mock<() => Promise> + copyPaths: Mock<() => Promise> + effectiveHooks: Mock + created: { + path: string + head: string + branch: string + isBare: boolean + isMainWorktree: boolean + } +} + +export const runtimeCreateMocks: RuntimeCreateMocks = { + git: vi.fn(), + rearm: vi.fn(), + routing: vi.fn<() => { wslDistro?: string }>(), + defaultBase: vi.fn(), + hasBase: vi.fn(), + branchName: vi.fn(), + canCheckout: vi.fn(), + pathExists: vi.fn(), + branchConflict: vi.fn(), + githubPr: vi.fn(), + consume: vi.fn(), + add: vi.fn(), + addSparse: vi.fn(), + pushTarget: vi.fn(), + listing: vi.fn(), + remoteBase: vi.fn(), + hasRemoteRef: vi.fn(), + refresh: vi.fn(), + fetch: vi.fn(), + resolveShared: vi.fn<() => Promise>(), + resolveInclude: vi.fn<() => Promise>(), + copyPaths: vi.fn<() => Promise>(), + effectiveHooks: vi.fn(), + created: { + path: '', + head: 'abc123', + branch: 'app', + isBare: false, + isMainWorktree: false + } +} + +export const worktreePath = resolve('/worktrees', 'app') + +export const pickedBranch = { + baseBranch: 'origin/main', + branchNameOverride: 'main', + branchIntent: 'open-existing' +} as const + +export const writtenMeta: Partial[] = [] +const WEB_PRESET: SparsePreset = { + id: 'preset-web', + repoId: 'repo-1', + name: 'web', + directories: ['apps/web', 'packages/ui'], + createdAt: 0, + updatedAt: 0 +} +export const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>() + +export async function createWorktree( + request: Partial = {}, + rearm: PreparationRearmHolder = { fire: () => {} }, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), + repoOverrides: Partial = {}, + autoRenameBranchFromWork = false +) { + const store = { + getSettings: () => ({ + workspaceDir: '/worktrees', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: '', + autoRenameBranchFromWork + }), + setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial) => { + writtenMeta.push(updates) + return updates + }, + getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId) + } + // Loaded on call so module mock factories can import this harness without a cycle. + const { createRuntimeLocalManagedWorktree } = await import('./runtime-local-worktree-create') + return createRuntimeLocalManagedWorktree({ + request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, + repo: { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: '#000000', + addedAt: 0, + ...repoOverrides + }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above. + store: store as Store, + createdWithAgent: undefined, + resolveRemoteTrackingBase: runtimeCreateMocks.remoteBase, + hasRemoteTrackingRef: runtimeCreateMocks.hasRemoteRef, + // vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection. + refreshRemoteTrackingBase: async (...refreshArgs) => + await runtimeCreateMocks.refresh(...refreshArgs), + fetchRemote: async (...fetchArgs) => await runtimeCreateMocks.fetch(...fetchArgs), + onWorktreeMetadataPersisted: () => undefined, + rearm, + timing + }) +} + +export function resetRuntimeCreateMocks(): void { + vi.resetAllMocks() + writtenMeta.length = 0 + runtimeCreateMocks.created.branch = 'app' + runtimeCreateMocks.git.mockResolvedValue({ stdout: '', stderr: '' }) + runtimeCreateMocks.created.path = worktreePath + runtimeCreateMocks.routing.mockReturnValue({}) + runtimeCreateMocks.defaultBase.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return 'main' + }) + runtimeCreateMocks.hasBase.mockResolvedValue(true) + runtimeCreateMocks.branchName.mockResolvedValue('app') + runtimeCreateMocks.canCheckout.mockResolvedValue(false) + runtimeCreateMocks.pathExists.mockResolvedValue(false) + runtimeCreateMocks.branchConflict.mockResolvedValue(null) + runtimeCreateMocks.githubPr.mockResolvedValue(null) + runtimeCreateMocks.consume.mockResolvedValue({ + status: 'hit', + result: {}, + rearm: runtimeCreateMocks.rearm + }) + runtimeCreateMocks.add.mockResolvedValue({}) + runtimeCreateMocks.addSparse.mockResolvedValue({}) + runtimeCreateMocks.listing.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return { created: runtimeCreateMocks.created } + }) + runtimeCreateMocks.remoteBase.mockImplementation(async () => { + expect(resolveGitAdmissionTier()).toBe('interactive') + return null + }) + runtimeCreateMocks.hasRemoteRef.mockResolvedValue(true) + runtimeCreateMocks.refresh.mockResolvedValue({ ok: true }) + runtimeCreateMocks.fetch.mockResolvedValue(undefined) + runtimeCreateMocks.resolveShared.mockResolvedValue([]) + runtimeCreateMocks.resolveInclude.mockResolvedValue(['.env']) + runtimeCreateMocks.copyPaths.mockResolvedValue([]) + runtimeCreateMocks.effectiveHooks.mockReturnValue(null) + readSparsePresets.mockReturnValue([WEB_PRESET]) +} diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index d10d57e3774..9466dd387d3 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -6,52 +6,24 @@ import { import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work' import { beforeEach, describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' -import type { Store } from '../persistence' -import type { WorktreeMeta } from '../../shared/worktree/meta-types' -import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity' import type { Repo } from '../../shared/repo-types' import { getDefaultRepoHookSettings } from '../../shared/constants' -import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { AddWorktreeOptions } from '../git/worktree' import { acquireGitAdmission, GitAdmissionScheduler, _resetGitAdmissionForTests } from '../git/command-runner/git-subprocess-admission' -import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor' -const mocks = vi.hoisted(() => ({ - git: vi.fn(), - rearm: vi.fn(), - routing: vi.fn<() => { wslDistro?: string }>(), - defaultBase: vi.fn(), - hasBase: vi.fn(), - branchName: vi.fn(), - canCheckout: vi.fn(), - pathExists: vi.fn(), - branchConflict: vi.fn(), - githubPr: vi.fn(), - consume: vi.fn(), - add: vi.fn(), - addSparse: vi.fn(), - pushTarget: vi.fn(), - listing: vi.fn(), - remoteBase: vi.fn(), - hasRemoteRef: vi.fn(), - refresh: vi.fn(), - fetch: vi.fn(), - resolveShared: vi.fn<() => Promise>(), - resolveInclude: vi.fn<() => Promise>(), - copyPaths: vi.fn<() => Promise>(), - effectiveHooks: vi.fn(), - created: { - path: '', - head: 'abc123', - branch: 'app', - isBare: false, - isMainWorktree: false - } -})) +const { + runtimeCreateMocks: mocks, + worktreePath, + pickedBranch, + writtenMeta, + readSparsePresets, + createWorktree, + resetRuntimeCreateMocks +} = await vi.hoisted(async () => await import('./runtime-local-worktree-create-test-harness')) vi.mock('../text-generation/commit-message-text-generation', () => ({ resolveTextGenerationParams: () => ({ @@ -105,117 +77,12 @@ vi.mock('../ipc/worktree-symlinks', () => ({ createWorktreeSharedPaths: vi.fn() })) -import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import { WorktreeCreateSetupChoiceRefusedError } from './worktree-create-setup-choice-refusal' import type { PreparationRearmHolder } from '../worktree-create-preparation' -import { - createWorktreeCreateTimingRecorder, - type WorktreeCreateTimingRecorder -} from '../worktree-create-timing' -import type { SparsePreset } from '../../shared/worktree/create-types' +import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' import { withoutInFlightWorktreeCreates } from '../worktree-create-in-flight' -const worktreePath = resolve('/worktrees', 'app') - -const pickedBranch = { - baseBranch: 'origin/main', - branchNameOverride: 'main', - branchIntent: 'open-existing' -} as const - -const writtenMeta: Partial[] = [] -const WEB_PRESET: SparsePreset = { - id: 'preset-web', - repoId: 'repo-1', - name: 'web', - directories: ['apps/web', 'packages/ui'], - createdAt: 0, - updatedAt: 0 -} -const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>() - -function createWorktree( - request: Partial = {}, - rearm: PreparationRearmHolder = { fire: () => {} }, - timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(), - repoOverrides: Partial = {}, - autoRenameBranchFromWork = false -) { - const store = { - getSettings: () => ({ - workspaceDir: '/worktrees', - nestWorkspaces: false, - refreshLocalBaseRefOnWorktreeCreate: false, - branchPrefix: '', - autoRenameBranchFromWork - }), - setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial) => { - writtenMeta.push(updates) - return updates - }, - getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId) - } - return createRuntimeLocalManagedWorktree({ - request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request }, - repo: { - id: 'repo-1', - path: '/repo', - displayName: 'Repo', - badgeColor: '#000000', - addedAt: 0, - ...repoOverrides - }, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above. - store: store as Store, - createdWithAgent: undefined, - resolveRemoteTrackingBase: mocks.remoteBase, - hasRemoteTrackingRef: mocks.hasRemoteRef, - // vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection. - refreshRemoteTrackingBase: async (...refreshArgs) => await mocks.refresh(...refreshArgs), - fetchRemote: async (...fetchArgs) => await mocks.fetch(...fetchArgs), - onWorktreeMetadataPersisted: () => undefined, - rearm, - timing - }) -} - -beforeEach(() => { - vi.resetAllMocks() - writtenMeta.length = 0 - mocks.created.branch = 'app' - mocks.git.mockResolvedValue({ stdout: '', stderr: '' }) - mocks.created.path = worktreePath - mocks.routing.mockReturnValue({}) - mocks.defaultBase.mockImplementation(async () => { - expect(resolveGitAdmissionTier()).toBe('interactive') - return 'main' - }) - mocks.hasBase.mockResolvedValue(true) - mocks.branchName.mockResolvedValue('app') - mocks.canCheckout.mockResolvedValue(false) - mocks.pathExists.mockResolvedValue(false) - mocks.branchConflict.mockResolvedValue(null) - mocks.githubPr.mockResolvedValue(null) - mocks.consume.mockResolvedValue({ status: 'hit', result: {}, rearm: mocks.rearm }) - mocks.add.mockResolvedValue({}) - mocks.addSparse.mockResolvedValue({}) - mocks.listing.mockImplementation(async () => { - expect(resolveGitAdmissionTier()).toBe('interactive') - return { created: mocks.created } - }) - mocks.remoteBase.mockImplementation(async () => { - expect(resolveGitAdmissionTier()).toBe('interactive') - return null - }) - mocks.hasRemoteRef.mockResolvedValue(true) - mocks.refresh.mockResolvedValue({ ok: true }) - mocks.fetch.mockResolvedValue(undefined) - mocks.resolveShared.mockResolvedValue([]) - mocks.resolveInclude.mockResolvedValue(['.env']) - mocks.copyPaths.mockResolvedValue([]) - mocks.effectiveHooks.mockReturnValue(null) - readSparsePresets.mockReturnValue([WEB_PRESET]) -}) +beforeEach(resetRuntimeCreateMocks) describe('runtime create listing hold', () => { const rows = [{ path: worktreePath }] diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredPaneMenuItems.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredPaneMenuItems.tsx new file mode 100644 index 00000000000..cc0076c792a --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatStructuredPaneMenuItems.tsx @@ -0,0 +1,57 @@ +import { PanelBottomClose, PanelRightClose, X } from 'lucide-react' +import { + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut +} from '@/components/ui/dropdown-menu' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' +import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions' + +export function NativeChatStructuredPaneMenuItems({ + pane, + leadingSeparator, + splitShortcutLabels, + resolveOrcaSessionId +}: { + pane: { tabId: string; worktreeId: string; groupId: string } + leadingSeparator: boolean + splitShortcutLabels?: { right: string; down: string } + resolveOrcaSessionId?: () => Promise +}): React.JSX.Element { + const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId) + const hasSplitPanes = useAppStore( + (state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1 + ) + return ( + <> + {leadingSeparator ? : null} + + + {translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')} + {splitShortcutLabels?.right} + + + + {translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')} + {splitShortcutLabels?.down} + + {resolveOrcaSessionId ? ( + <> + + + + ) : null} + {hasSplitPanes && actions.canClosePane ? ( + <> + + + + {actions.closeLabel} + + + ) : null} + + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 091ca58f7a7..f89f5988613 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -32,6 +32,7 @@ import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSession import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' import { StructuredChatPaneMenuItems } from './StructuredChatPaneMenuItems' import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems' +import { NativeChatStructuredPaneMenuItems } from './NativeChatStructuredPaneMenuItems' import type { NativeChatComposerHandle } from './native-chat-composer-types' import { AgentSessionContinuationMenuItem } from '@/components/terminal-pane/AgentSessionContinuationMenuItem' import { AgentSessionContextCopyMenuItem } from '@/components/terminal-pane/AgentSessionContextCopyMenuItem' @@ -313,7 +314,7 @@ export function useNativeChatContextMenu({ ) : null} {chatPane ? ( -