diff --git a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts index c46bd3ea5c2..3960141bacc 100644 --- a/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts +++ b/src/renderer/src/hooks/useComposerState-host-context-boundaries.test.ts @@ -124,6 +124,32 @@ describe('useComposerState host-context boundaries', () => { expect(section).toMatch(/\.catch\(\(error: unknown\) =>/) }) + it('clears only repo-scoped linked work items when the repo or project changes', () => { + // Why: Linear and Jira issues are workspace-scoped context — a repo or + // project switch must keep them attached. Jira used to be dropped because + // this path special-cased Linear only. + const repoChangeSection = sourceBetween( + HOOK_SOURCE, + 'const handleRepoChange', + 'const handleFolderSourceRepoChange' + ) + expect(repoChangeSection).toContain( + '!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)' + ) + + const folderSourceSection = sourceBetween( + HOOK_SOURCE, + 'const handleFolderSourceRepoChange', + 'const handleProjectHostSetupChange' + ) + expect(folderSourceSection).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(current)') + + // No switch path may gate the linked-item clear on a Linear-only predicate + // again. (isLinearLinkedWorkItem itself may still appear — it drives the + // separate Linear branch-name feature — but never the preservation decision.) + expect(HOOK_SOURCE).not.toContain('if (!preserveLinearLinkedWorkItem)') + }) + it('does not use local SSH gates for runtime-owned folder targets', () => { const targetSection = sourceBetween( HOOK_SOURCE, @@ -568,7 +594,7 @@ describe('useComposerState host-context boundaries', () => { 'const handleProjectChange = useCallback', 'const handleSmartGitHubItemSelect' ) - expect(section).toContain("linkedProvider !== 'linear' && linkedProvider !== 'jira'") + expect(section).toContain('!shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)') }) it('resolves quick-create base refs through the worktree-create precedence helper', () => { diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index accc351ca50..7afcbc308af 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -149,7 +149,8 @@ import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-s import { getForkPushWarning } from './fork-push-warning' import { buildWorkspaceSourceSelection, - shouldApplyWorkspaceSourceAutoName + shouldApplyWorkspaceSourceAutoName, + shouldPreserveWorkspaceSourceOnRepoChange } from '../../../shared/new-workspace/workspace-source' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -2525,8 +2526,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - // Why: a repo change invalidates repo-scoped sources, but a Linear issue is workspace-scoped and must survive choosing the implementation project. - if (!preserveLinearLinkedWorkItem) { + // Why: a repo change invalidates repo-scoped sources, but Linear and + // Jira issues are workspace-scoped and must survive choosing the + // implementation project — not just Linear. + if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { setLinkedWorkItem(null) } } @@ -2559,10 +2562,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } setRepoId(value) smartGitHubPrStartPointSelectionRef.current = null - setLinkedWorkItem((current) => { - const provider = current ? getLinkedWorkItemProvider(current) : null - return provider === 'github' || provider === 'gitlab' ? null : current - }) + setLinkedWorkItem((current) => + current && !shouldPreserveWorkspaceSourceOnRepoChange(current) ? null : current + ) setLinkedIssue('') setLinkedPR(null) setLinkedGitLabIssue(null) @@ -2607,8 +2609,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS setLinkedPR(null) setLinkedGitLabIssue(null) setLinkedGitLabMR(null) - const linkedProvider = linkedWorkItem ? getLinkedWorkItemProvider(linkedWorkItem) : null - if (linkedWorkItem && linkedProvider !== 'linear' && linkedProvider !== 'jira') { + if (linkedWorkItem && !shouldPreserveWorkspaceSourceOnRepoChange(linkedWorkItem)) { setLinkedWorkItem(null) } setSparseEnabled(false) diff --git a/src/shared/new-workspace/workspace-source.test.ts b/src/shared/new-workspace/workspace-source.test.ts index ca1ea34b3a8..1f9a50f8c45 100644 --- a/src/shared/new-workspace/workspace-source.test.ts +++ b/src/shared/new-workspace/workspace-source.test.ts @@ -34,6 +34,26 @@ describe('workspace source policy', () => { it('preserves global work-item sources across repo changes', () => { expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'jira', + type: 'issue', + number: 0, + title: 'Workspace scoped', + url: 'https://acme.atlassian.net/browse/FUS-1' + }) + ).toBe(true) + // Why: Jira items picked from smart search may arrive without an explicit + // provider; preservation must still hold via URL/identifier inference. + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + type: 'issue', + number: 0, + title: 'Inferred Jira', + url: 'https://acme.atlassian.net/browse/FUS-1', + jiraIdentifier: 'FUS-1' + }) + ).toBe(true) expect( shouldPreserveWorkspaceSourceOnRepoChange({ provider: 'github', @@ -43,6 +63,27 @@ describe('workspace source policy', () => { url: 'https://github.com/o/r/issues/1' }) ).toBe(false) + // Why: GitLab is repo-scoped; pin both the explicit MR and the + // URL-inferred shape clear, since folder-source/project-group paths delegate here. + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + provider: 'gitlab', + type: 'mr', + number: 2, + title: 'Repo scoped MR', + url: 'https://gitlab.com/o/r/-/merge_requests/2' + }) + ).toBe(false) + expect( + shouldPreserveWorkspaceSourceOnRepoChange({ + type: 'issue', + number: 3, + title: 'Inferred GitLab', + url: 'https://gitlab.example.com/g/p/-/work_items/3' + }) + ).toBe(false) + // Why: a null source (branch-only) has nothing to preserve; callers guard on this. + expect(shouldPreserveWorkspaceSourceOnRepoChange(null)).toBe(false) }) it('shares provider inference, selection labels, and auto-name gates', () => { diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts new file mode 100644 index 00000000000..5500bb223a2 --- /dev/null +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -0,0 +1,141 @@ +/** + * E2E: linked work item behavior when switching the composer project. + * + * Why: Jira/Linear issues are workspace-scoped context — switching the + * implementation project must keep them attached. They used to be dropped, + * leaving only the derived name in the smart field. GitHub/GitLab sources + * are repo-scoped and must still clear on a project switch. + * + * Why E2E: the preservation logic lives in useComposerState behind the real + * ProjectCombobox interaction and main-process repo resolution — a store + * slice unit test cannot reach the combobox → handleProjectChange → smart + * field pill re-render path. + */ + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import type { LinkedWorkItemSummary } from '../../src/renderer/src/lib/new-workspace' + +const SECOND_PROJECT_NAME = 'linked-item-second-project' + +function runGit(repoPath: string, args: string[]): void { + execFileSync('git', args, { cwd: repoPath, stdio: 'pipe' }) +} + +function createGitRepo(repoPath: string): void { + mkdirSync(repoPath, { recursive: true }) + runGit(repoPath, ['init']) + runGit(repoPath, ['config', 'user.email', 'e2e@test.local']) + runGit(repoPath, ['config', 'user.name', 'E2E Test']) + writeFileSync(path.join(repoPath, 'README.md'), '# Linked item project switch E2E\n') + runGit(repoPath, ['add', '-A']) + runGit(repoPath, ['commit', '-m', 'Initial commit']) +} + +async function addSecondProject(page: Page, repoPath: string): Promise { + await page.evaluate(async (targetRepoPath) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const addedRepo = await store.getState().addRepoPath(targetRepoPath) + if (!addedRepo) { + throw new Error(`Failed to add repo at ${targetRepoPath}`) + } + }, repoPath) +} + +async function openComposerWithLinkedWorkItem( + page: Page, + linkedWorkItem: LinkedWorkItemSummary, + prefilledName: string +): Promise { + await page.evaluate( + ({ linkedWorkItem, prefilledName }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.getState().openModal('new-workspace-composer', { linkedWorkItem, prefilledName }) + }, + { linkedWorkItem, prefilledName } + ) +} + +async function switchComposerProject(page: Page, projectName: string): Promise { + const composer = page.getByRole('dialog') + const combobox = composer.locator('button[data-project-combobox-root="true"]') + await combobox.click() + await page.getByRole('option', { name: new RegExp(projectName) }).click() + await expect(combobox).toContainText(projectName) +} + +test.describe('New workspace composer linked item across project switches', () => { + let tempRoot: string + let secondRepoPath: string + + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + tempRoot = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-linked-item-')) + secondRepoPath = path.join(tempRoot, SECOND_PROJECT_NAME) + createGitRepo(secondRepoPath) + await addSecondProject(orcaPage, secondRepoPath) + }) + + test.afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }) + }) + + test('keeps a Jira issue linked when the project changes', async ({ orcaPage }) => { + await openComposerWithLinkedWorkItem( + orcaPage, + { + type: 'issue', + provider: 'jira', + number: 0, + title: 'RDG-344 Migrate homepage from NuxtJS to NextJS', + url: 'https://example.atlassian.net/browse/RDG-344', + jiraIdentifier: 'RDG-344' + }, + 'rdg-344-nuxtjs-nextjs' + ) + + const composer = orcaPage.getByRole('dialog') + await expect(composer).toBeVisible() + const sourcePill = composer.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS') + + await switchComposerProject(orcaPage, SECOND_PROJECT_NAME) + + await expect(sourcePill).toContainText('RDG-344 Migrate homepage from NuxtJS to NextJS') + }) + + test('clears a repo-scoped GitHub issue when the project changes', async ({ orcaPage }) => { + await openComposerWithLinkedWorkItem( + orcaPage, + { + type: 'issue', + provider: 'github', + number: 41, + title: 'Fix crash on launch', + url: 'https://github.com/acme/app/issues/41' + }, + 'fix-crash-on-launch' + ) + + const composer = orcaPage.getByRole('dialog') + await expect(composer).toBeVisible() + const sourcePill = composer.locator('[data-workspace-source-pill="true"]') + await expect(sourcePill).toContainText('#41 Fix crash on launch') + + await switchComposerProject(orcaPage, SECOND_PROJECT_NAME) + + await expect(sourcePill).toHaveCount(0) + }) +})