Improve linked work item naming (#4674)

This commit is contained in:
Trevin Chow
2026-06-04 23:25:30 -07:00
committed by GitHub
parent 254ca64a2a
commit 8406299a0e
15 changed files with 458 additions and 70 deletions
@@ -1,8 +1,8 @@
// Why: when "Auto-Rename Branch From Work" is on, the first time an agent
// starts working in a freshly-created workspace we replace the auto-generated
// creature branch (e.g. `you/Nautilus`) with a short, work-derived name. This
// module owns the orchestration: gate on the signal, enforce the safety
// guardrails, summarize the prompt via the configured agent, and rename.
// Why: when first-message auto-name is on, the first time an agent starts
// working in a freshly-created workspace we replace the auto-generated creature
// branch (e.g. `you/Nautilus`) with a short, work-derived name. This module
// owns the orchestration: gate on the signal, enforce the safety guardrails,
// summarize the prompt via the configured agent, and rename.
import type { GlobalSettings, Repo } from '../../shared/types'
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
import { parsePaneKey } from '../../shared/stable-pane-id'
+38 -3
View File
@@ -130,6 +130,7 @@ import { JiraIcon } from '@/components/icons/JiraIcon'
import { cn } from '@/lib/utils'
import {
getLinkedWorkItemSuggestedName,
getWorkspaceIntentName,
getTaskPresetQuery,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT
@@ -330,6 +331,40 @@ const GITHUB_TASK_ROW_SURFACE_CLASS =
'[background:color-mix(in_srgb,var(--muted)_50%,var(--background))]'
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)
)
}
function getGitLabWorkItemWorkspaceSeed(item: GitLabWorkItem): string {
return (
getWorkspaceIntentName({
workItem: {
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
}
})?.seedName ?? getLinkedWorkItemSuggestedName(issue)
)
}
// Why: the row's px-3 left padding leaves a 12px gap between the scroll-viewport
// edge and the sticky ID column; without a covering ::before, scrolled cell text
// bleeds through that strip. Same trick as the title column for its 8px gap.
@@ -5134,7 +5169,7 @@ export default function TaskPage(): React.JSX.Element {
}
openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName: getLinkedWorkItemSuggestedName(item),
prefilledName: getGitHubWorkItemWorkspaceSeed(item),
initialRepoId: item.repoId,
telemetrySource: 'sidebar'
})
@@ -5191,7 +5226,7 @@ export default function TaskPage(): React.JSX.Element {
}
openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName: getLinkedWorkItemSuggestedName(item),
prefilledName: getGitLabWorkItemWorkspaceSeed(item),
initialRepoId: item.repoId,
telemetrySource: 'sidebar'
})
@@ -6183,7 +6218,7 @@ export default function TaskPage(): React.JSX.Element {
}
openModal('new-workspace-composer', {
linkedWorkItem,
prefilledName: getLinkedWorkItemSuggestedName(issue),
prefilledName: getJiraIssueWorkspaceSeed(issue),
telemetrySource: 'sidebar'
})
},
@@ -13,7 +13,7 @@ import {
} from '@/components/ui/command'
import { branchName } from '@/lib/git-utils'
import { parseGitHubIssueOrPRNumber, parseGitHubIssueOrPRLink } from '@/lib/github-links'
import { getLinkedWorkItemSuggestedName } from '@/lib/new-workspace'
import { getLinkedWorkItemSuggestedName, getWorkspaceIntentName } 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'
@@ -1034,7 +1034,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName = getLinkedWorkItemSuggestedName({ title: item.title })
data.prefilledName =
getWorkspaceIntentName({ sourceText: trimmed, workItem: linkedWorkItem })?.seedName ??
getLinkedWorkItemSuggestedName({ title: item.title })
} else {
// Fallback: we couldn't resolve the URL, just seed the name.
data.prefilledName = `${slug.owner}-${slug.repo}-${number}`
@@ -1097,7 +1099,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
url: item.url
}
data.linkedWorkItem = linkedWorkItem
data.prefilledName = getLinkedWorkItemSuggestedName({ title: item.title })
data.prefilledName =
getWorkspaceIntentName({ sourceText: trimmed, workItem: linkedWorkItem })?.seedName ??
getLinkedWorkItemSuggestedName({ title: item.title })
} else {
data.prefilledName = trimmed
}
@@ -7,9 +7,9 @@ import { cn } from '@/lib/utils'
/**
* Gear affordance on the workspace Name label row. Its popover explains that a
* blank name lets Orca auto-rename the branch from the work, and lets the user
* flip the `autoRenameBranchFromWork` setting inline without leaving the
* composer. Only relevant for git repos, where a branch exists to rename.
* blank name lets Orca auto-name the workspace title and branch from the work,
* and lets the user flip the `autoRenameBranchFromWork` setting inline without
* leaving the composer. Only relevant for git repos, where a branch exists.
*/
export default function AutoRenameBranchHint(): React.JSX.Element {
const autoRenameBranchFromWork = useAppStore((s) => s.settings?.autoRenameBranchFromWork ?? false)
@@ -37,7 +37,7 @@ export default function AutoRenameBranchHint(): React.JSX.Element {
// order like the adjacent agent-settings gear.
tabIndex={-1}
className="size-5 shrink-0 rounded-sm text-muted-foreground hover:text-foreground data-[state=open]:text-foreground"
aria-label="Auto-rename branch settings"
aria-label="Auto-name settings"
>
<Settings2 className="size-3" />
</Button>
@@ -46,12 +46,12 @@ export default function AutoRenameBranchHint(): React.JSX.Element {
<div className="space-y-2.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-foreground">Auto-rename branch</span>
<span className="text-sm font-medium text-foreground">Auto-name from work</span>
<button
type="button"
role="switch"
aria-checked={autoRenameBranchFromWork}
aria-label="Auto-rename branch"
aria-label="Auto-name from work"
onClick={() =>
updateSettings({ autoRenameBranchFromWork: !autoRenameBranchFromWork })
}
@@ -69,8 +69,8 @@ export default function AutoRenameBranchHint(): React.JSX.Element {
</button>
</div>
<p className="text-xs text-muted-foreground">
When you leave the name blank, Orca renames the branch to match the work once an agent
starts.
When you leave the name blank, Orca uses the first task to rename the sidebar title
and unpublished generated branch.
</p>
</div>
@@ -281,11 +281,14 @@ export function AutoRenameBranchFromWorkSetting({
return (
<SearchableSetting
title="Auto-Rename Branch"
description="Rename the auto-generated branch based on the work once an agent starts."
title="Auto-Name From First Message"
description="Use the first task to name blank new workspaces and their unpublished branches."
keywords={[
'workspace',
'title',
'branch',
'rename',
'name',
'auto',
'creature name',
'agent',
@@ -300,11 +303,11 @@ export function AutoRenameBranchFromWorkSetting({
>
<div ref={setSettingRootRef} className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<Label>Auto-Rename Branch</Label>
<Label>Auto-name from first message</Label>
<p className="text-xs text-muted-foreground">
When an agent starts working in a new workspace, Orca renames its auto-generated branch
(e.g. <code>Nautilus</code>) to a short name summarizing the task. Only branches Orca
named itself are renamed, and never after they have been pushed.
When a blank new workspace starts work, Orca uses the first task to rename the sidebar
title and unpublished generated branch (e.g. <code>Nautilus</code>). Workspaces created
from linked issues or pull requests are named up front from the same short identity.
</p>
</div>
<button
@@ -1,11 +1,14 @@
import type { SettingsSearchEntry } from './settings-search'
export const AUTO_RENAME_BRANCH_PARENT_SEARCH_ENTRY: SettingsSearchEntry = {
title: 'Auto-Rename Branch',
description: 'Rename the auto-generated branch based on the work once an agent starts.',
title: 'Auto-Name From First Message',
description: 'Use the first task to name blank new workspaces and their unpublished branches.',
keywords: [
'workspace',
'title',
'branch',
'rename',
'name',
'auto',
'creature name',
'agent',
+50 -13
View File
@@ -45,6 +45,7 @@ import {
getLinkedWorkItemProvider,
getLinkedWorkItemSuggestedName,
getSetupConfig,
getWorkspaceIntentName,
getWorkspaceSeedName,
isGitLabIssueUrl,
PER_REPO_FETCH_LIMIT,
@@ -1144,7 +1145,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
title: item.title,
url: item.url
})
const suggestedName = getLinkedWorkItemSuggestedName(item)
const suggestedName =
getWorkspaceIntentName({
sourceText: name,
workItem: item
})?.seedName ?? getLinkedWorkItemSuggestedName(item)
if (suggestedName && (!name.trim() || name === lastAutoNameRef.current)) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
@@ -1179,7 +1184,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
throw new Error('Could not resolve the GitHub item before creating the workspace.')
}
const resolution = getSmartGitHubSubmitResolution(item)
const resolution = getSmartGitHubSubmitResolution(item, {
sourceText: [name, agentPrompt, noteRef.current].filter(Boolean).join('\n')
})
// 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.
@@ -1196,14 +1203,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
branchAutoNameRef.current = ''
setStartFromResetHint(null)
return resolution
}, [linkedWorkItem, name, selectedRepo, selectedRepoIsGit])
}, [agentPrompt, linkedWorkItem, name, selectedRepo, selectedRepoIsGit])
// Why: parallel of applyLinkedWorkItem for GitLab. Touches the GitLab
// state slots only — the GitHub linkedIssue/linkedPR remain unchanged
// so a workspace can in principle reference items from both providers.
// The auto-name logic mirrors the GitHub side (issue: number-and-title,
// MR: branch name) via getLinkedWorkItemSuggestedName, which already
// accepts both shapes structurally.
const applyLinkedGitLabWorkItem = useCallback(
(item: GitLabWorkItem): void => {
if (item.type === 'issue') {
@@ -1229,9 +1233,19 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
title: item.title,
branchName: item.branchName
} as unknown as GitHubWorkItem)
if (suggestedName && (!name.trim() || name === lastAutoNameRef.current)) {
setName(suggestedName)
lastAutoNameRef.current = suggestedName
const intentName = getWorkspaceIntentName({
sourceText: name,
workItem: {
type: item.type,
provider: 'gitlab',
number: item.number,
title: item.title
}
})
const nextName = intentName?.seedName ?? suggestedName
if (nextName && (!name.trim() || name === lastAutoNameRef.current)) {
setName(nextName)
lastAutoNameRef.current = nextName
}
setBranchNameOverride(undefined)
},
@@ -1861,7 +1875,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const submitLinkedIssueNumber =
smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber
const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR
const workspaceName = smartGitHubResolution?.workspaceName ?? workspaceSeedName
const submitIntentName = submitLinkedWorkItem
? getWorkspaceIntentName({
sourceText: [name, agentPrompt, note].filter(Boolean).join('\n'),
workItem: submitLinkedWorkItem
})
: null
const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current
const workspaceName =
smartGitHubResolution?.workspaceName ??
(nameIsAutoManaged && submitIntentName ? submitIntentName.seedName : workspaceSeedName)
if (!workspaceName) {
return
}
@@ -1927,7 +1950,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const createDisplayName = smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title
const createDisplayName =
smartGitHubResolution?.displayName ??
(nameIsAutoManaged ? submitIntentName?.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 =
@@ -2124,7 +2149,16 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const submitLinkedIssueNumber =
smartGitHubResolution?.linkedIssueNumber ?? parsedLinkedIssueNumber
const submitLinkedPR = smartGitHubResolution?.linkedPR ?? effectiveLinkedPR
const workspaceName = smartGitHubResolution?.workspaceName ?? workspaceNameSeed
const submitIntentName = submitLinkedWorkItem
? getWorkspaceIntentName({
sourceText: [name, agentPrompt, note].filter(Boolean).join('\n'),
workItem: submitLinkedWorkItem
})
: null
const nameIsAutoManaged = !name.trim() || name === lastAutoNameRef.current
const workspaceName =
smartGitHubResolution?.workspaceName ??
(nameIsAutoManaged && submitIntentName ? submitIntentName.seedName : workspaceNameSeed)
if (!workspaceName) {
return
}
@@ -2173,7 +2207,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
workspaceName,
preserveWorkspaceNameEdits: branchNameOverridePreservesNameEdits
})
const createDisplayName = smartGitHubResolution?.displayName ?? submitLinkedWorkItem?.title
const createDisplayName =
smartGitHubResolution?.displayName ??
(nameIsAutoManaged ? submitIntentName?.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 =
@@ -2332,6 +2368,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
},
[
agentPrompt,
applyWorktreeMeta,
baseBranch,
branchNameOverride,
@@ -47,7 +47,21 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({
vi.mock('@/lib/new-workspace', () => ({
CLIENT_PLATFORM: 'darwin',
getLinkedWorkItemSuggestedName: (item: { title: string }) => item.title,
getWorkspaceIntentName: (args: {
workItem?: { type: 'issue' | 'pr' | 'mr'; number: number; title: string } | null
}) =>
args.workItem
? {
displayName:
args.workItem.type === 'pr'
? `Review PR ${args.workItem.number}`
: `Issue ${args.workItem.number}`,
seedName:
args.workItem.type === 'pr'
? `review-pr-${args.workItem.number}`
: `issue-${args.workItem.number}`
}
: null,
getSetupConfig: vi.fn(() => null),
getWorkspaceSeedName: ({ explicitName }: { explicitName?: string }) => explicitName ?? '',
isGitLabIssueUrl: vi.fn(() => false)
@@ -105,7 +119,7 @@ describe('launchWorkItemDirect', () => {
globalThis.window = { api: mockApi }
})
it('passes a resolved PR branch override while keeping the PR title as the workspace display name', async () => {
it('passes a resolved PR branch override while using a short PR identity for workspace names', async () => {
await launchWorkItemDirect({
repoId: 'repo-1',
launchSource: 'task_page',
@@ -121,12 +135,12 @@ describe('launchWorkItemDirect', () => {
expect(storeState.value.createWorktree).toHaveBeenCalledWith(
'repo-1',
'Fix the bug',
'review-pr-42',
'abc123',
'inherit',
undefined,
'sidebar',
'Fix the bug',
'Review PR 42',
undefined,
42,
{ remoteName: 'origin', branchName: 'feature/fix' },
@@ -8,7 +8,7 @@ import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/wor
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import {
CLIENT_PLATFORM,
getLinkedWorkItemSuggestedName,
getWorkspaceIntentName,
getSetupConfig,
getWorkspaceSeedName,
isGitLabIssueUrl
@@ -214,10 +214,17 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
const finalSetupDecision: SetupDecision =
trustDecision === 'skip' ? 'skip' : setupResolution.decision
const workspaceIntentName =
item.number !== null
? getWorkspaceIntentName({
sourceText: item.pasteContent,
workItem: { ...item, number: item.number }
})
: null
const workspaceName = getWorkspaceSeedName({
explicitName: item.linearIdentifier
? getLinearIssueWorkspaceName({ identifier: item.linearIdentifier, title: item.title })
: getLinkedWorkItemSuggestedName(item),
: (workspaceIntentName?.seedName ?? ''),
prompt: '',
linkedIssueNumber: item.type === 'issue' ? (item.number ?? null) : null,
linkedPR: item.type === 'pr' ? (item.number ?? null) : null
@@ -254,7 +261,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom
finalSetupDecision,
undefined,
telemetrySource,
item.title,
workspaceIntentName?.displayName ?? item.title,
item.type === 'issue' && item.number ? item.number : undefined,
item.type === 'pr' && item.number ? item.number : undefined,
resolvedPushTarget,
+1
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 { getWorkspaceIntentName } from '../../../shared/workspace-name'
/**
* Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need
@@ -25,6 +25,20 @@ describe('getSmartGitHubSubmitIntent', () => {
})
})
it('finds a GitHub item URL embedded in a short instruction', () => {
expect(
getSmartGitHubSubmitIntent(
'https://github.com/mvanhorn/cli-printing-press/issues/2635 and fix it'
)
).toEqual({
kind: 'link',
owner: 'mvanhorn',
repo: 'cli-printing-press',
number: 2635,
type: 'issue'
})
})
it('treats #number as source intent but leaves plain numbers as names', () => {
expect(getSmartGitHubSubmitIntent('#2049')).toEqual({
kind: 'hash-number',
@@ -228,17 +242,20 @@ describe('lookupSmartGitHubSubmitItem', () => {
})
describe('getSmartGitHubSubmitResolution', () => {
it('uses the resolved item title for workspace name and linked PR metadata', () => {
it('uses short intent identity 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'
})
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' }
)
).toEqual({
workspaceName: 'fix-smart-resolution-delay',
displayName: 'Fix smart resolution delay',
workspaceName: 'review-pr-2049',
displayName: 'Review PR 2049',
linkedWorkItem: {
type: 'pr',
number: 2049,
@@ -250,15 +267,19 @@ describe('getSmartGitHubSubmitResolution', () => {
})
})
it('uses the resolved item title for workspace name and 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'
})
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' }
)
expect(resolution.workspaceName).toBe('make-create-feel-instant')
expect(resolution.workspaceName).toBe('fix-issue-2050')
expect(resolution.displayName).toBe('Fix Issue 2050')
expect(resolution.linkedIssueNumber).toBe(2050)
expect(resolution.linkedPR).toBeNull()
})
+18 -5
View File
@@ -1,5 +1,5 @@
import type { GitHubWorkItem } from '../../../shared/types'
import { getLinkedWorkItemSuggestedName } from '../../../shared/workspace-name'
import { getWorkspaceIntentName } from '../../../shared/workspace-name'
import type { LinkedWorkItemSummary } from './new-workspace'
import { parseGitHubIssueOrPRLink } from './github-links'
@@ -45,6 +45,7 @@ export type SmartGitHubSubmitLookup = {
const SMART_GITHUB_SUBMIT_LOOKUP_TTL_MS = 60_000
const SMART_GITHUB_SUBMIT_LOOKUP_CACHE_MAX_ENTRIES = 128
const GITHUB_ITEM_URL_RE = /https?:\/\/(?:www\.)?github\.com\/\S+/i
type SmartGitHubSubmitLookupCacheEntry = {
expiresAt: number
@@ -74,7 +75,7 @@ export function getSmartGitHubSubmitIntent(input: string): SmartGitHubSubmitInte
return null
}
const link = parseGitHubIssueOrPRLink(trimmed)
const link = parseGitHubIssueOrPRLink(trimmed) ?? parseGitHubIssueOrPRLinkFromText(trimmed)
if (link) {
return {
kind: 'link',
@@ -95,6 +96,13 @@ export function getSmartGitHubSubmitIntent(input: string): SmartGitHubSubmitInte
return null
}
function parseGitHubIssueOrPRLinkFromText(
input: string
): ReturnType<typeof parseGitHubIssueOrPRLink> {
const match = GITHUB_ITEM_URL_RE.exec(input)
return match ? parseGitHubIssueOrPRLink(match[0]) : null
}
function getSmartGitHubSubmitLookupCacheKey({
repoId,
repoPath,
@@ -168,10 +176,15 @@ export function getSmartGitHubSubmitLookupCacheSizeForTests(): number {
}
export function getSmartGitHubSubmitResolution(
item: Pick<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>
item: Pick<GitHubWorkItem, 'number' | 'title' | 'type' | 'url'>,
options: { sourceText?: string } = {}
): SmartGitHubSubmitResolution {
const fallbackName = `${item.type}-${item.number}`
const workspaceName = getLinkedWorkItemSuggestedName(item) || fallbackName
const intentName = getWorkspaceIntentName({
sourceText: options.sourceText,
workItem: item
})
const workspaceName = intentName?.seedName || fallbackName
const linkedWorkItem: LinkedWorkItemSummary = {
type: item.type,
number: item.number,
@@ -181,7 +194,7 @@ export function getSmartGitHubSubmitResolution(
return {
workspaceName,
displayName: item.title,
displayName: intentName?.displayName ?? fallbackName,
linkedWorkItem,
linkedIssueNumber: item.type === 'issue' ? item.number : null,
linkedPR: item.type === 'pr' ? item.number : null
+1 -1
View File
@@ -163,7 +163,7 @@ export const CONTEXTUAL_TOURS = [
},
{
title: 'Name it, or start from existing work',
body: 'Start a workspace from a task source to inherit the title. Or leave it blank to auto-name it from your first agent message.',
body: 'Start from a linked task for a short issue or PR name. Or leave it blank to auto-name it from your first agent message.',
targetSelector: '[data-contextual-tour-target="workspace-creation-name"]',
control: { kind: 'auto-rename-branch-from-work' }
},
+90
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
getLinearIssueWorkspaceName,
getLinkedWorkItemSuggestedName,
getWorkspaceIntentName,
resolveWorkspaceCreateName,
slugifyForWorkspaceName
} from './workspace-name'
@@ -25,6 +26,95 @@ describe('getLinkedWorkItemSuggestedName', () => {
})
})
describe('getWorkspaceIntentName', () => {
it('uses explicit user intent for linked issues without copying long titles', () => {
expect(
getWorkspaceIntentName({
sourceText: 'https://github.com/mvanhorn/cli-printing-press/issues/2635 and fix it',
workItem: {
type: 'issue',
number: 2635,
title:
"scorer/dogfood: live acceptance can't authenticate via the CLI's config/cookie credentials (scoped-home is env-only)"
}
})
).toEqual({
displayName: 'Fix Issue 2635',
seedName: 'fix-issue-2635'
})
})
it('defaults PR and MR work to review-oriented identities', () => {
expect(
getWorkspaceIntentName({
sourceText: 'https://github.com/acme/app/pull/1234 and check whether this is safe',
workItem: {
type: 'pr',
number: 1234,
title: 'Refactor account settings panel'
}
})
).toEqual({
displayName: 'Review PR 1234',
seedName: 'review-pr-1234'
})
expect(
getWorkspaceIntentName({
sourceText: 'fix https://gitlab.com/acme/app/-/merge_requests/77',
workItem: {
type: 'mr',
provider: 'gitlab',
number: 77,
title: 'Resolve sync race'
}
})
).toEqual({
displayName: 'Fix MR 77',
seedName: 'fix-mr-77'
})
})
it('uses a compressed subject when a linked issue has no action', () => {
expect(
getWorkspaceIntentName({
sourceText: 'https://github.com/acme/app/issues/9876',
workItem: {
type: 'issue',
number: 9876,
title: 'Make importer handle archived rows'
}
})
).toEqual({
displayName: 'Issue 9876 Make Importer Handle',
seedName: 'issue-9876-make-importer-handle'
})
})
it('uses external provider identifiers without duplicating them in the subject', () => {
expect(
getWorkspaceIntentName({
workItem: {
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'
})
})
it('summarizes unlinked task text into a shared display and seed', () => {
expect(getWorkspaceIntentName({ sourceText: 'add keyboard shortcut settings' })).toEqual({
displayName: 'Add Keyboard Shortcut Settings',
seedName: 'add-keyboard-shortcut-settings'
})
})
})
describe('getLinearIssueWorkspaceName', () => {
it('keeps the Linear identifier in the workspace seed', () => {
expect(
+160
View File
@@ -28,6 +28,166 @@ export function getLinkedWorkItemSuggestedName(item: { title: string }): string
return slugifyForWorkspaceName(seed)
}
export type WorkspaceIntentWorkItem = {
type: 'issue' | 'pr' | 'mr'
number: number
title: string
provider?: 'github' | 'gitlab' | 'linear' | 'jira'
linearIdentifier?: string
jiraIdentifier?: string
}
export type WorkspaceIntentName = {
displayName: string
seedName: string
}
const ACTION_LABELS: [RegExp, string][] = [
[/\bfix(?:e[sd])?\b|\bresolve\b|\brepair\b/i, 'Fix'],
[/\bdebug\b|\bdiagnose\b/i, 'Debug'],
[/\breview\b|\blook\s+over\b|\binspect\b|\bcheck\b|\bsafe\b|\bsafety\b/i, 'Review'],
[/\bimplement\b|\bbuild\b|\bship\b/i, 'Implement'],
[/\binvestigate\b|\bunderstand\b|\btriage\b/i, 'Investigate'],
[/\badd\b|\bcreate\b/i, 'Add'],
[/\bupdate\b|\bchange\b/i, 'Update'],
[/\brefactor\b|\bsimplify\b/i, 'Refactor'],
[/\btest\b|\bverify\b|\bvalidate\b/i, 'Test']
]
const STOP_WORDS = new Set([
'a',
'an',
'and',
'for',
'from',
'in',
'is',
'it',
'of',
'on',
'or',
'the',
'this',
'to',
'with'
])
function detectIntentAction(sourceText: string): string | null {
for (const [pattern, label] of ACTION_LABELS) {
if (pattern.test(sourceText)) {
return label
}
}
return null
}
function titleCaseWord(word: string): string {
const lower = word.toLowerCase()
if (/^[A-Z]{2,}\d*$/.test(word) || /^[A-Z]+-\d+$/i.test(word)) {
return word.toUpperCase()
}
return lower.charAt(0).toUpperCase() + lower.slice(1)
}
function compactWords(input: string, maxWords = 4): string {
return input
.replace(/https?:\/\/\S+/gi, ' ')
.replace(/[()[\]{}"']/g, ' ')
.replace(/[#/\\:_-]+/g, ' ')
.split(/\s+/)
.map((word) => word.trim())
.filter(Boolean)
.filter((word) => !STOP_WORDS.has(word.toLowerCase()))
.slice(0, maxWords)
.map(titleCaseWord)
.join(' ')
}
function escapeRegExp(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function compactWorkItemTitle(title: string, item: WorkspaceIntentWorkItem): string {
const identifier = item.linearIdentifier ?? item.jiraIdentifier
let withoutPrefix = title
.trim()
.replace(/^(?:issue|pr|pull request|mr|merge request)\s*[#!]?\d+\s*[:-]\s*/i, '')
.replace(/\([#!]?\d+\)/g, '')
.replace(/^[^:]{1,32}:\s*/, '')
.trim()
if (item.number > 0) {
withoutPrefix = withoutPrefix.replace(new RegExp(`\\b[#!]?${item.number}\\b`, 'g'), '').trim()
}
if (identifier) {
withoutPrefix = withoutPrefix
.replace(new RegExp(`^${escapeRegExp(identifier)}\\s*[:-]?\\s*`, 'i'), '')
.trim()
}
return compactWords(withoutPrefix || title, 3)
}
function workItemIdentity(item: WorkspaceIntentWorkItem): string {
if (item.linearIdentifier) {
return item.linearIdentifier.toUpperCase()
}
if (item.jiraIdentifier) {
return item.jiraIdentifier.toUpperCase()
}
if (item.type === 'pr') {
return `PR ${item.number}`
}
if (item.type === 'mr') {
return `MR ${item.number}`
}
return `Issue ${item.number}`
}
function defaultActionForWorkItem(item: WorkspaceIntentWorkItem): string | null {
return item.type === 'pr' || item.type === 'mr' ? 'Review' : null
}
/**
* Resolve the one human intent label that should drive first-create workspace
* identity. The display label and git-safe seed are derived together so the
* folder, branch, and sidebar name do not drift before work has started.
*/
export function getWorkspaceIntentName(args: {
sourceText?: string
workItem?: WorkspaceIntentWorkItem | null
fallbackName?: string
}): WorkspaceIntentName | null {
const sourceText = args.sourceText?.trim() ?? ''
const item = args.workItem ?? null
let displayName = ''
if (item) {
const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item)
const identity = workItemIdentity(item)
if (action) {
displayName = `${action} ${identity}`
} else {
const subject = compactWorkItemTitle(item.title, item)
displayName = [identity, subject].filter(Boolean).join(' ')
}
} else if (sourceText) {
const compact = compactWords(sourceText, 5)
displayName = compact
}
if (!displayName && args.fallbackName?.trim()) {
displayName = args.fallbackName.trim()
}
if (!displayName) {
return null
}
const seedName = slugifyForWorkspaceName(displayName)
if (!seedName) {
return null
}
return { displayName, seedName }
}
export function getLinearIssueWorkspaceName(issue: { identifier: string; title: string }): string {
const key = slugifyForWorkspaceName(issue.identifier)
const titleSlug = getLinkedWorkItemSuggestedName(issue)