diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 58e771477dd..4f92736af63 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -639,6 +639,13 @@ export default function SmartWorkspaceNameField({ () => (sourceQueryWithinLimit ? parseGitHubIssueOrPRLink(debouncedQuery) : null), [debouncedQuery, sourceQueryWithinLimit] ) + const githubUrlIntent = useMemo( + () => + isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'github') + ? parseGitHubIssueOrPRLink(value) + : null, + [mode, value] + ) const linearUrlIntent = useMemo( () => parseBoundedSmartWorkspaceLinearIssueUrlIntent(value), [value] @@ -1270,6 +1277,7 @@ export default function SmartWorkspaceNameField({ value, debouncedQuery }), + githubUrlIntent, gitlabAvailable: gitlabSourceAvailable, gitlabItems: getVisibleHeldProviderResults({ items: gitlabItems, @@ -1302,6 +1310,7 @@ export default function SmartWorkspaceNameField({ branchResultsSource, debouncedQuery, githubItems, + githubUrlIntent, gitlabSourceAvailable, gitlabItems, jiraSource.accountChoices, diff --git a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts index 780800bb9a4..2983b0f7680 100644 --- a/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts +++ b/src/renderer/src/components/new-workspace/smart-workspace-source-results.test.ts @@ -230,6 +230,100 @@ describe('Branch source results', () => { ).toEqual([{ id: 'default-1' }]) }) + it('hides unrelated held results while a full GitHub URL resolves', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const staleGitHubItem = { + id: 'issue-17', + type: 'issue' as const, + number: 17, + title: 'Wrong cached issue', + state: 'open' as const, + url: 'https://github.com/acme/widgets/issues/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + + expect( + buildSmartWorkspaceSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'acme', repo: 'widgets', host: 'github.com' }, + type: 'issue', + number: 42 + }, + branches: [{ refName: 'origin/issues/17', localBranchName: 'issues/17' }], + githubItems: [ + staleGitHubItem, + { + ...staleGitHubItem, + id: 'same-number-other-repo', + number: 42, + url: 'https://github.com/acme/other-repo/issues/42' + }, + { + ...staleGitHubItem, + id: 'same-number-pull-request', + type: 'pr', + number: 42, + url: 'https://github.com/acme/widgets/pull/42' + } + ], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + }) + ).toEqual([{ kind: 'use-name', value: 'use-name', name: targetUrl }]) + }) + + it('shows only the exact GitHub URL result after lookup settles', () => { + const targetUrl = 'https://github.com/acme/widgets/issues/42' + const exactItem = { + id: 'issue-42', + type: 'issue' as const, + number: 42, + title: 'Exact pasted issue', + state: 'open' as const, + url: targetUrl, + labels: [], + updatedAt: '2026-08-02T00:00:00.000Z', + author: 'octocat', + repoId: 'repo-1' + } + const staleItem = { + ...exactItem, + id: 'issue-17', + number: 17, + url: `${targetUrl.slice(0, -2)}17` + } + + expect( + buildSmartWorkspaceSourceRows({ + mode: 'smart', + value: targetUrl, + githubUrlIntent: { + slug: { owner: 'ACME', repo: 'Widgets', host: 'GitHub.com' }, + type: 'issue', + number: 42 + }, + branches: [], + githubItems: [staleItem, exactItem], + gitlabItems: [], + linearIssues: [], + gitlabAvailable: false, + linearAvailable: false, + resultLimit: 12 + }) + ).toEqual([ + { kind: 'use-name', value: 'use-name', name: targetUrl }, + { kind: 'github', value: 'github-repo-1-issue-42', item: exactItem } + ]) + }) + it('uses stable cmdk values for typed-text actions', () => { expect( buildSmartWorkspaceSourceRows({ diff --git a/src/shared/new-workspace/smart-workspace-source-results.ts b/src/shared/new-workspace/smart-workspace-source-results.ts index 40de888f150..662c8eaa3f5 100644 --- a/src/shared/new-workspace/smart-workspace-source-results.ts +++ b/src/shared/new-workspace/smart-workspace-source-results.ts @@ -5,6 +5,8 @@ import type { LinearIssue } from '../linear/issue-types' import type { LinearCollectionResult } from '../linear/workspace-types' import type { BaseRefSearchResult } from '../repo-types' import { JIRA_ISSUE_KEY_PATTERN, parseJiraIssueUrl } from '../jira-issue-url' +import { parseGitHubIssueOrPRLink, type GitHubIssueOrPRLink } from '../github/links' +import { githubRepoIdentityKey } from '../github/repository-identity-key' import { isSmartWorkspaceLinearIssueIntentMatch, parseBoundedSmartWorkspaceLinearIssueUrlIntent @@ -63,6 +65,16 @@ function toJiraSourceRow(issue: JiraIssue): SmartWorkspaceSourceRow { return { kind: 'jira', value: `jira-${issue.siteId ?? ''}-${issue.key}`, issue } } +function isGitHubLinkIntentMatch(intent: GitHubIssueOrPRLink, item: GitHubWorkItem): boolean { + const itemLink = parseGitHubIssueOrPRLink(item.url) + return ( + itemLink !== null && + itemLink.type === intent.type && + itemLink.number === intent.number && + githubRepoIdentityKey(itemLink.slug) === githubRepoIdentityKey(intent.slug) + ) +} + export function getBranchSearchRequest({ branchesEnabled, disabled, @@ -186,6 +198,7 @@ export function shouldHoldSourceResultsForQuery({ export function buildSmartWorkspaceSourceRows({ branches, githubItems, + githubUrlIntent, gitlabAvailable, gitlabItems, jiraIntent = false, @@ -200,6 +213,7 @@ export function buildSmartWorkspaceSourceRows({ }: { branches: BaseRefSearchResult[] githubItems: GitHubWorkItem[] + githubUrlIntent?: GitHubIssueOrPRLink | null gitlabAvailable: boolean gitlabItems: GitLabWorkItem[] jiraIntent?: boolean @@ -220,6 +234,20 @@ export function buildSmartWorkspaceSourceRows({ return [] } const trimmed = value.trim() + if (githubUrlIntent && (mode === 'smart' || mode === 'github')) { + const githubRows = githubItems + .filter((item) => isGitHubLinkIntentMatch(githubUrlIntent, item)) + .map((item) => ({ + kind: 'github' as const, + value: `github-${item.repoId}-${item.type}-${item.number}`, + item + })) + .slice(0, resultLimit) + // Why: the full URL is unambiguous, so unrelated held provider rows must never become selectable. + return trimmed && mode === 'smart' + ? [{ kind: 'use-name' as const, value: 'use-name', name: trimmed }, ...githubRows] + : githubRows + } const nextRows: SmartWorkspaceSourceRow[] = [] const resolvedLinearIssues = Array.isArray(linearIssues) ? linearIssues diff --git a/tests/e2e/github-url-smart-input-transition.spec.ts b/tests/e2e/github-url-smart-input-transition.spec.ts new file mode 100644 index 00000000000..38cfebf53e4 --- /dev/null +++ b/tests/e2e/github-url-smart-input-transition.spec.ts @@ -0,0 +1,180 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import type { GitHubWorkItem } from '../../src/shared/github/work-item-types' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const TARGET_URL = 'https://github.com/stablyai/orca/issues/4242' +const WRONG_TITLE = 'Wrong cached issue' +const TARGET_TITLE = 'Exact pasted issue' + +const WRONG_ITEM: GitHubWorkItem = { + id: 'issue-17', + type: 'issue', + number: 17, + title: WRONG_TITLE, + state: 'open', + url: 'https://github.com/stablyai/orca/issues/17', + labels: [], + updatedAt: '2026-08-01T00:00:00.000Z', + author: 'e2e', + repoId: 'e2e-repo' +} + +const TARGET_ITEM: GitHubWorkItem = { + ...WRONG_ITEM, + id: 'issue-4242', + number: 4242, + title: TARGET_TITLE, + url: TARGET_URL, + updatedAt: '2026-08-02T00:00:00.000Z' +} + +type TransitionFrame = { + value: string + wrongVisible: boolean + wrongSelected: boolean + targetVisible: boolean + targetSelected: boolean +} + +function pasteChord(): string { + return process.platform === 'darwin' ? 'Meta+V' : 'Control+V' +} + +async function installHeldGitHubLookup( + electronApp: ElectronApplication, + page: Page +): Promise { + await electronApp.evaluate(({ ipcMain }, targetItem) => { + const fixture = globalThis as unknown as { + __githubUrlLookupStarted?: boolean + __releaseGitHubUrlLookup?: () => void + } + fixture.__githubUrlLookupStarted = false + ipcMain.removeHandler('gh:workItemByOwnerRepo') + ipcMain.handle('gh:workItemByOwnerRepo', () => { + fixture.__githubUrlLookupStarted = true + return new Promise((resolve) => { + fixture.__releaseGitHubUrlLookup = () => resolve(targetItem) + }) + }) + }, TARGET_ITEM) + await page.evaluate((wrongItem) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState({ + getCachedWorkItems: () => [wrongItem], + fetchWorkItems: async () => [wrongItem], + fetchWorkItemsAcrossRepos: async () => ({ + items: [wrongItem], + failedCount: 0, + githubUnavailable: false + }) + }) + }, WRONG_ITEM) +} + +async function releaseGitHubLookup(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __releaseGitHubUrlLookup?: () => void } + if (!fixture.__releaseGitHubUrlLookup) { + throw new Error('GitHub lookup is not held') + } + fixture.__releaseGitHubUrlLookup() + }) +} + +test('a pasted GitHub URL never selects a stale cached issue', async ({ + electronApp, + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installHeldGitHubLookup(electronApp, orcaPage) + + await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() + const dialog = orcaPage.getByRole('dialog', { name: /Create (Workspace|Worktree)/i }) + const input = dialog.locator('[data-workspace-name-input="true"]') + await expect(input).toBeVisible() + await input.click() + + const wrongOption = orcaPage.getByRole('option', { name: `#17 ${WRONG_TITLE}`, exact: true }) + const targetOption = orcaPage.getByRole('option', { + name: `#4242 ${TARGET_TITLE}`, + exact: true + }) + await expect(wrongOption).toBeVisible() + + await orcaPage.evaluate( + ({ wrongTitle, targetTitle }) => { + const frames: TransitionFrame[] = [] + const capture = (): void => { + const input = document.querySelector('[data-workspace-name-input="true"]') + const options = [...document.querySelectorAll('[role="option"]')] + const wrong = options.find((option) => option.textContent?.includes(wrongTitle)) + const target = options.find((option) => option.textContent?.includes(targetTitle)) + frames.push({ + value: input?.value ?? '', + wrongVisible: Boolean(wrong && wrong.getClientRects().length > 0), + wrongSelected: wrong?.dataset.selected === 'true', + targetVisible: Boolean(target && target.getClientRects().length > 0), + targetSelected: target?.dataset.selected === 'true' + }) + requestAnimationFrame(capture) + } + Reflect.set(window, '__githubUrlTransitionFrames', frames) + capture() + }, + { wrongTitle: WRONG_TITLE, targetTitle: TARGET_TITLE } + ) + + await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), TARGET_URL) + await input.focus() + await orcaPage.keyboard.press(pasteChord()) + await expect + .poll(() => + electronApp.evaluate(() => { + const fixture = globalThis as unknown as { __githubUrlLookupStarted?: boolean } + return fixture.__githubUrlLookupStarted === true + }) + ) + .toBe(true) + await expect + .poll(() => + orcaPage.evaluate((url) => { + const frames = Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] + return frames.filter((frame) => frame.value === url).length + }, TARGET_URL) + ) + .toBeGreaterThan(10) + await expect(wrongOption).toHaveCount(0) + await expect(targetOption).toHaveCount(0) + + await releaseGitHubLookup(electronApp) + await expect(targetOption).toBeVisible() + await expect(targetOption).toHaveAttribute('data-selected', 'true') + await expect + .poll(() => + orcaPage.evaluate(() => { + const frames = Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] + return frames.some((frame) => frame.targetVisible && frame.targetSelected) + }) + ) + .toBe(true) + + const frames = await orcaPage.evaluate(() => { + return Reflect.get(window, '__githubUrlTransitionFrames') as TransitionFrame[] + }) + const pastedFrames = frames.filter((frame) => frame.value === TARGET_URL) + expect(frames.some((frame) => frame.wrongVisible)).toBe(true) + expect(pastedFrames.length).toBeGreaterThan(10) + expect(pastedFrames.every((frame) => !frame.wrongVisible && !frame.wrongSelected)).toBe(true) + expect(pastedFrames.some((frame) => frame.targetVisible && frame.targetSelected)).toBe(true) + + await testInfo.attach('github-url-smart-input-fixed.png', { + body: await orcaPage.screenshot(), + contentType: 'image/png' + }) +})