diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index cba4c2790..20104a123 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -130,7 +130,7 @@ import { JiraIcon } from '@/components/icons/JiraIcon' import { cn } from '@/lib/utils' import { getLinkedWorkItemSuggestedName, - getWorkspaceIntentName, + getLinkedWorkItemWorkspaceName, getTaskPresetQuery, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT @@ -335,34 +335,28 @@ const GITHUB_TASK_ROW_HOVER_SURFACE_CLASS = 'group-hover/github-task-row:[background:color-mix(in_srgb,var(--muted)_70%,var(--background))]' function getGitHubWorkItemWorkspaceSeed(item: GitHubWorkItem): string { - return ( - getWorkspaceIntentName({ workItem: item })?.seedName ?? getLinkedWorkItemSuggestedName(item) - ) + return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) } function getGitLabWorkItemWorkspaceSeed(item: GitLabWorkItem): string { return ( - getWorkspaceIntentName({ - workItem: { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title - } + getLinkedWorkItemWorkspaceName({ + type: item.type, + provider: 'gitlab', + number: item.number, + title: item.title })?.seedName ?? getLinkedWorkItemSuggestedName(item) ) } function getJiraIssueWorkspaceSeed(issue: JiraIssue): string { return ( - getWorkspaceIntentName({ - workItem: { - type: 'issue', - provider: 'jira', - number: 0, - title: `${issue.key} ${issue.title}`, - jiraIdentifier: issue.key - } + getLinkedWorkItemWorkspaceName({ + type: 'issue', + provider: 'jira', + number: 0, + title: `${issue.key} ${issue.title}`, + jiraIdentifier: issue.key })?.seedName ?? getLinkedWorkItemSuggestedName(issue) ) } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index df697a0da..23addcb26 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/command' import { branchName } from '@/lib/git-utils' import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links' -import { getLinkedWorkItemSuggestedName, getWorkspaceIntentName } from '@/lib/new-workspace' +import { getLinkedWorkItemSuggestedName, getLinkedWorkItemWorkspaceName } from '@/lib/new-workspace' import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' import { isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' @@ -1037,7 +1037,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } data.linkedWorkItem = linkedWorkItem data.prefilledName = - getWorkspaceIntentName({ sourceText: trimmed, workItem: linkedWorkItem })?.seedName ?? + getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? getLinkedWorkItemSuggestedName({ title: item.title }) } else { // Fallback: we couldn't resolve the URL, just seed the name. @@ -1102,7 +1102,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } data.linkedWorkItem = linkedWorkItem data.prefilledName = - getWorkspaceIntentName({ sourceText: trimmed, workItem: linkedWorkItem })?.seedName ?? + getLinkedWorkItemWorkspaceName(linkedWorkItem)?.seedName ?? getLinkedWorkItemSuggestedName({ title: item.title }) } else { data.prefilledName = trimmed diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 399124995..2b86d05fe 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -45,8 +45,8 @@ import { getAttachmentLabel, getLinkedWorkItemProvider, getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName, getSetupConfig, - getWorkspaceIntentName, getWorkspaceSeedName, isGitLabIssueUrl, PER_REPO_FETCH_LIMIT, @@ -1154,10 +1154,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS url: item.url }) const suggestedName = - getWorkspaceIntentName({ - sourceText: name, - workItem: item - })?.seedName ?? getLinkedWorkItemSuggestedName(item) + getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item) if (suggestedName && (!name.trim() || name === lastAutoNameRef.current)) { setName(suggestedName) lastAutoNameRef.current = suggestedName @@ -1192,9 +1189,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS throw new Error('Could not resolve the GitHub item before creating the workspace.') } - const resolution = getSmartGitHubSubmitResolution(item, { - sourceText: [name, agentPrompt, noteRef.current].filter(Boolean).join('\n') - }) + const resolution = getSmartGitHubSubmitResolution(item) // Why: Create can be clicked before the debounced smart field commits // its selected source. Commit the resolved item here so failures leave // the form showing the title instead of the raw URL. @@ -1211,7 +1206,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS branchAutoNameRef.current = '' setStartFromResetHint(null) return resolution - }, [agentPrompt, linkedWorkItem, name, selectedRepo, selectedRepoIsGit]) + }, [linkedWorkItem, name, selectedRepo, selectedRepoIsGit]) // Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab // state slots only — the GitHub linkedIssue/linkedPR remain unchanged @@ -1241,16 +1236,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS title: item.title, branchName: item.branchName } as unknown as GitHubWorkItem) - const intentName = getWorkspaceIntentName({ - sourceText: name, - workItem: { - type: item.type, - provider: 'gitlab', - number: item.number, - title: item.title - } + const titleName = getLinkedWorkItemWorkspaceName({ + type: item.type, + provider: 'gitlab', + number: item.number, + title: item.title }) - const nextName = intentName?.seedName ?? suggestedName + const nextName = titleName?.seedName ?? suggestedName if (nextName && (!name.trim() || name === lastAutoNameRef.current)) { setName(nextName) lastAutoNameRef.current = nextName @@ -1894,16 +1886,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const submitLinkedIssueNumber = smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR - const submitIntentName = submitLinkedWorkItem - ? getWorkspaceIntentName({ - sourceText: [name, agentPrompt, note].filter(Boolean).join('\n'), - workItem: submitLinkedWorkItem - }) + const submitTitleName = submitLinkedWorkItem + ? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem) : null const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current const workspaceName = smartGitHubResolution?.workspaceName ?? - (nameIsAutoManaged && submitIntentName ? submitIntentName.seedName : workspaceSeedName) + (nameIsAutoManaged && submitTitleName ? submitTitleName.seedName : workspaceSeedName) if (!workspaceName) { return } @@ -1971,7 +1960,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) const createDisplayName = smartGitHubResolution?.displayName ?? - (nameIsAutoManaged ? submitIntentName?.displayName : undefined) + (nameIsAutoManaged ? submitTitleName?.displayName : undefined) // Why: the first-work hook only renames blank, auto-generated git workspaces // that actually launch an agent. Persist that known-pending state for the card. const pendingFirstAgentMessageRename = @@ -2168,16 +2157,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS const submitLinkedIssueNumber = smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR - const submitIntentName = submitLinkedWorkItem - ? getWorkspaceIntentName({ - sourceText: [name, agentPrompt, note].filter(Boolean).join('\n'), - workItem: submitLinkedWorkItem - }) + const submitTitleName = submitLinkedWorkItem + ? getLinkedWorkItemWorkspaceName(submitLinkedWorkItem) : null const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current const workspaceName = smartGitHubResolution?.workspaceName ?? - (nameIsAutoManaged && submitIntentName ? submitIntentName.seedName : workspaceNameSeed) + (nameIsAutoManaged && submitTitleName ? submitTitleName.seedName : workspaceNameSeed) if (!workspaceName) { return } @@ -2228,7 +2214,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS }) const createDisplayName = smartGitHubResolution?.displayName ?? - (nameIsAutoManaged ? submitIntentName?.displayName : undefined) + (nameIsAutoManaged ? submitTitleName?.displayName : undefined) // Why: quick create uses the same blank-name creature branch flow; the card // needs an explicit marker rather than guessing from the generated title. const pendingFirstAgentMessageRename = @@ -2346,7 +2332,6 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS } }, [ - agentPrompt, baseBranch, branchNameOverride, branchNameOverridePreservesNameEdits, diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index d3a346d2c..b92f3008f 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -12,6 +12,7 @@ import { resolveHookCommandSourcePolicy } from '../../../shared/hook-command-sou import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { slugifyForWorkspaceName } from '../../../shared/workspace-name' export { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name' +export { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' export { getWorkspaceIntentName } from '../../../shared/workspace-name' /** diff --git a/src/renderer/src/lib/smart-github-submit.test.ts b/src/renderer/src/lib/smart-github-submit.test.ts index a81728c43..43ec3c363 100644 --- a/src/renderer/src/lib/smart-github-submit.test.ts +++ b/src/renderer/src/lib/smart-github-submit.test.ts @@ -264,20 +264,17 @@ describe('lookupSmartGitHubSubmitItem', () => { }) describe('getSmartGitHubSubmitResolution', () => { - it('uses short intent identity for workspace name, display name, and linked PR metadata', () => { + it('uses the resolved PR title for workspace name, display name, and linked PR metadata', () => { expect( - getSmartGitHubSubmitResolution( - { - type: 'pr', - number: 2049, - title: 'Fix smart resolution delay', - url: 'https://github.com/stablyai/orca/pull/2049' - }, - { sourceText: 'review https://github.com/stablyai/orca/pull/2049' } - ) + getSmartGitHubSubmitResolution({ + type: 'pr', + number: 2049, + title: 'Fix smart resolution delay', + url: 'https://github.com/stablyai/orca/pull/2049' + }) ).toEqual({ - workspaceName: 'review-pr-2049', - displayName: 'Review PR 2049', + workspaceName: 'fix-smart-resolution-delay', + displayName: 'Fix smart resolution delay', linkedWorkItem: { type: 'pr', number: 2049, @@ -289,19 +286,16 @@ describe('getSmartGitHubSubmitResolution', () => { }) }) - it('uses user intent instead of the raw title for linked issue metadata', () => { - const resolution = getSmartGitHubSubmitResolution( - { - type: 'issue', - number: 2050, - title: 'Issue #2050: Make create feel instant', - url: 'https://github.com/stablyai/orca/issues/2050' - }, - { sourceText: 'https://github.com/stablyai/orca/issues/2050 and fix it' } - ) + it('strips duplicated issue prefixes while preserving linked issue metadata', () => { + const resolution = getSmartGitHubSubmitResolution({ + type: 'issue', + number: 2050, + title: 'Issue #2050: Make create feel instant', + url: 'https://github.com/stablyai/orca/issues/2050' + }) - expect(resolution.workspaceName).toBe('fix-issue-2050') - expect(resolution.displayName).toBe('Fix Issue 2050') + expect(resolution.workspaceName).toBe('make-create-feel-instant') + expect(resolution.displayName).toBe('Make create feel instant') expect(resolution.linkedIssueNumber).toBe(2050) expect(resolution.linkedPR).toBeNull() }) diff --git a/src/renderer/src/lib/smart-github-submit.ts b/src/renderer/src/lib/smart-github-submit.ts index ad936240e..572053f1f 100644 --- a/src/renderer/src/lib/smart-github-submit.ts +++ b/src/renderer/src/lib/smart-github-submit.ts @@ -1,5 +1,5 @@ import type { GitHubWorkItem } from '../../../shared/types' -import { getWorkspaceIntentName } from '../../../shared/workspace-name' +import { getLinkedWorkItemWorkspaceName } from '../../../shared/workspace-name' import type { LinkedWorkItemSummary } from './new-workspace' import { parseGitHubIssueOrPRLink } from './github-links' @@ -179,15 +179,11 @@ export function getSmartGitHubSubmitLookupCacheSizeForTests(): number { } export function getSmartGitHubSubmitResolution( - item: Pick, - options: { sourceText?: string } = {} + item: Pick ): SmartGitHubSubmitResolution { const fallbackName = `${item.type}-${item.number}` - const intentName = getWorkspaceIntentName({ - sourceText: options.sourceText, - workItem: item - }) - const workspaceName = intentName?.seedName || fallbackName + const titleName = getLinkedWorkItemWorkspaceName(item) + const workspaceName = titleName?.seedName || fallbackName const linkedWorkItem: LinkedWorkItemSummary = { type: item.type, number: item.number, @@ -197,7 +193,7 @@ export function getSmartGitHubSubmitResolution( return { workspaceName, - displayName: intentName?.displayName ?? fallbackName, + displayName: titleName?.displayName ?? fallbackName, linkedWorkItem, linkedIssueNumber: item.type === 'issue' ? item.number : null, linkedPR: item.type === 'pr' ? item.number : null diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts index e7db0f41c..6d81f6424 100644 --- a/src/shared/workspace-name.test.ts +++ b/src/shared/workspace-name.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getLinearIssueWorkspaceName, getLinkedWorkItemSuggestedName, + getLinkedWorkItemWorkspaceName, getWorkspaceIntentName, resolveWorkspaceCreateName, slugifyForWorkspaceName @@ -35,6 +36,36 @@ describe('getLinkedWorkItemSuggestedName', () => { }) }) +describe('getLinkedWorkItemWorkspaceName', () => { + it('uses the resolved GitHub title instead of the source URL or provider number', () => { + expect( + getLinkedWorkItemWorkspaceName({ + type: 'pr', + number: 2049, + title: 'Fix pasted URL workspace names' + }) + ).toEqual({ + displayName: 'Fix pasted URL workspace names', + seedName: 'fix-pasted-url-workspace-names' + }) + }) + + it('keeps external provider identifiers without duplicating title prefixes', () => { + expect( + getLinkedWorkItemWorkspaceName({ + type: 'issue', + provider: 'jira', + number: 0, + title: 'PROJ-7 Fix flaky import', + jiraIdentifier: 'PROJ-7' + }) + ).toEqual({ + displayName: 'PROJ-7 Fix flaky import', + seedName: 'proj-7-fix-flaky-import' + }) + }) +}) + describe('getWorkspaceIntentName', () => { it('uses explicit user intent for linked issues without copying long titles', () => { expect( diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts index f1625ca06..b80d4462b 100644 --- a/src/shared/workspace-name.ts +++ b/src/shared/workspace-name.ts @@ -33,14 +33,7 @@ export function slugifyForWorkspaceName(input: string): string { } export function getLinkedWorkItemSuggestedName(item: { title: string }): string { - const withoutLeadingNumber = item.title - .trim() - .replace(/^(?:issue|pr|pull request)\s*#?\d+\s*[:-]\s*/i, '') - .replace(/^#\d+\s*[:-]\s*/, '') - .replace(/\(#\d+\)/gi, '') - .replace(/\b#\d+\b/g, '') - .trim() - const seed = withoutLeadingNumber || item.title.trim() + const seed = getLinkedWorkItemTitleSubject(item) || item.title.trim() return slugifyForWorkspaceName(seed) } @@ -58,6 +51,16 @@ export type WorkspaceIntentName = { seedName: string } +function getLinkedWorkItemTitleSubject(item: { title: string }): string { + return item.title + .trim() + .replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '') + .replace(/^#\d+\s*[:-]\s*/, '') + .replace(/\([#!]?\d+\)/g, '') + .replace(/\b#\d+\b/g, '') + .trim() +} + // Why: generated workspace seeds are hyphenated; `issue-123-fix-title` // must not be reinterpreted as the user explicitly asking to fix a new issue. const ACTION_LABELS: [RegExp, string][] = [ @@ -169,6 +172,24 @@ function workItemIdentity(item: WorkspaceIntentWorkItem): string { return `Issue ${item.number}` } +export function getLinkedWorkItemWorkspaceName( + item: WorkspaceIntentWorkItem +): WorkspaceIntentName | null { + const identifier = item.linearIdentifier ?? item.jiraIdentifier + let subject = getLinkedWorkItemTitleSubject(item) || item.title.trim() + if (identifier) { + subject = subject + .replace(new RegExp(`^${escapeRegExp(identifier)}\\s*[:-]?\\s*`, 'i'), '') + .trim() + } + const displayName = [identifier, subject].filter(Boolean).join(' ') || workItemIdentity(item) + const seedName = slugifyForWorkspaceName(displayName) + if (!seedName) { + return null + } + return { displayName, seedName } +} + function defaultActionForWorkItem(item: WorkspaceIntentWorkItem): string | null { return item.type === 'pr' || item.type === 'mr' ? 'Review' : null } diff --git a/tests/e2e/worktree.spec.ts b/tests/e2e/worktree.spec.ts index 68d3e7932..6c7956f12 100644 --- a/tests/e2e/worktree.spec.ts +++ b/tests/e2e/worktree.spec.ts @@ -221,7 +221,7 @@ test.describe('Create Workspace', () => { }) => { const title = `E2E smart URL resolution ${Date.now()}` const url = 'https://github.com/stablyai/orca/pull/2049' - const linkedWorkspacePattern = /Review PR 2049/ + const linkedWorkspacePattern = new RegExp(title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) try { await orcaPage.getByRole('button', { name: 'New workspace', exact: true }).click() @@ -298,6 +298,7 @@ test.describe('Create Workspace', () => { await expect(orcaPage.getByRole('option', { name: linkedWorkspacePattern })).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.getByRole('option', { name: url })).toHaveCount(0) await expect(orcaPage.getByText('Linked PR #2049')).toBeVisible() await expect .poll(() =>