Use work item titles for create-from workspace names

This commit is contained in:
Neil 2026-06-08 13:31:43 -07:00
parent 5337e93cb4
commit e5fe0f14d1
9 changed files with 120 additions and 97 deletions

View File

@ -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)
)
}

View File

@ -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

View File

@ -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,

View File

@ -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'
/**

View File

@ -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()
})

View File

@ -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<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>,
options: { sourceText?: string } = {}
item: Pick<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>
): 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

View File

@ -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(

View File

@ -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
}

View File

@ -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(() =>