mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
Improve linked work item naming (#4674)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user