From 9097ea0409eb9f5937671e17dccac7b4abb942a8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:06:31 -0700 Subject: [PATCH 1/2] fix(naming): remove identifier-first name post-processing (#9821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(naming): remove identifier-first name post-processing Workspace display names and tab titles were being rewritten after generation by prompt-scanning heuristics from #8238 — a stray "#1" in prose became a workspace named "#1 - Fix", and the rewrite ran downstream of generation so user naming instructions couldn't override it. Per the same principle as #9088, naming defaults stay minimal and user overrides own the style. - Delete work-item-reference.ts and display-name-from-work.ts (+tests). - Auto-rename display names return to the humanized branch slug; tab titles return to the cleaned first prompt clause. - Explicit create-from-work-item naming returns to action-first ("Review PR 1234"). - Keep #8238's URL-before-markdown strip-order bugfix in tab titles, with regression tests adjusted to the natural expectations. * test(naming): pin incidental marker regression --- .../first-work-branch-rename.test.ts | 6 +- .../agent-hooks/first-work-branch-rename.ts | 4 +- .../first-work-workspace-title-rename.ts | 4 +- .../src/lib/launch-work-item-direct.test.ts | 8 +- src/shared/agent-tab-title.test.ts | 44 +--- src/shared/agent-tab-title.ts | 18 -- src/shared/display-name-from-work.test.ts | 60 ------ src/shared/display-name-from-work.ts | 76 ------- src/shared/work-item-reference.test.ts | 127 ------------ src/shared/work-item-reference.ts | 190 ------------------ src/shared/workspace-name.test.ts | 12 +- src/shared/workspace-name.ts | 6 +- 12 files changed, 28 insertions(+), 527 deletions(-) delete mode 100644 src/shared/display-name-from-work.test.ts delete mode 100644 src/shared/display-name-from-work.ts delete mode 100644 src/shared/work-item-reference.test.ts delete mode 100644 src/shared/work-item-reference.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 1df5dbba930..e8d8cd5f373 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -82,9 +82,9 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { ) }) - it('renames a fresh creature branch and its display name from the generated slug', async () => { + it('keeps incidental work-item markers from overriding the generated display name', async () => { const { deps, onRenamed, setDisplayName } = makeDeps() - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['branch', '-m', 'you/fix-auth'], expect.objectContaining({ cwd: '/repo/wt' }) @@ -219,7 +219,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { getCurrentDisplayName: () => 'Platform workspace' }) - await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ prompt: 'Fix auth from note #1' }), deps) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith( diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 02ff623f52f..c9703c51637 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -4,10 +4,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../sha import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { + humanizeBranchSlug, isAutoGeneratedCreatureBranchName, stripConfiguredBranchPrefix } from '../../shared/branch-name-from-work' -import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' @@ -280,7 +280,7 @@ async function runAutoRename( const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1) // Only sync the sidebar name when it's still the auto-generated creature name, so a user-typed name is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) - const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) + const newDisplayName = humanizeBranchSlug(newBranchLeaf) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) if (updateDisplay) { deps.setDisplayName(worktreeId, newDisplayName) diff --git a/src/main/agent-hooks/first-work-workspace-title-rename.ts b/src/main/agent-hooks/first-work-workspace-title-rename.ts index 96bcdbde419..fb682c86cf3 100644 --- a/src/main/agent-hooks/first-work-workspace-title-rename.ts +++ b/src/main/agent-hooks/first-work-workspace-title-rename.ts @@ -1,4 +1,4 @@ -import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' +import { humanizeBranchSlug } from '../../shared/branch-name-from-work' import { generateBranchNameFromContext, resolveTextGenerationParams @@ -56,7 +56,7 @@ export async function runFolderWorkspaceTitleAutoRename( return retry(`generation failed: ${generated.error}`) } - const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) + const newDisplayName = humanizeBranchSlug(generated.slug) deps.setDisplayName(worktreeId, newDisplayName) deps.setRenameError(worktreeId, null) deps.onRenamed(worktreeId) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index c76c91bc030..55586d811c6 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -77,11 +77,11 @@ vi.mock('@/lib/new-workspace', () => ({ ? { displayName: args.workItem.type === 'pr' - ? `PR ${args.workItem.number} - Review` + ? `Review PR ${args.workItem.number}` : `Issue ${args.workItem.number}`, seedName: args.workItem.type === 'pr' - ? `pr-${args.workItem.number}-review` + ? `review-pr-${args.workItem.number}` : `issue-${args.workItem.number}` } : null, @@ -253,12 +253,12 @@ describe('launchWorkItemDirect', () => { }) expect(mocks.createWorktree).toHaveBeenCalledWith( 'repo-1', - 'pr-6934-review', + 'review-pr-6934', 'abc123', 'inherit', undefined, 'sidebar', - 'PR 6934 - Review', + 'Review PR 6934', undefined, 6934, { remoteName: 'origin', branchName: 'feature/fix' }, diff --git a/src/shared/agent-tab-title.test.ts b/src/shared/agent-tab-title.test.ts index 6ee20010459..c6932696f29 100644 --- a/src/shared/agent-tab-title.test.ts +++ b/src/shared/agent-tab-title.test.ts @@ -16,10 +16,12 @@ describe('deriveGeneratedTabTitle', () => { ).toBe('Refactor the auth middleware to use JWT') }) - it('strips markup, links, emoji, and punctuation from generated titles', () => { + it('strips markup, links, emoji, and punctuation without promoting incidental markers', () => { expect( - deriveGeneratedTabTitle('Please fix `src/auth.ts`!!! https://example.com 🔥 then add tests') - ).toBe('Fix src auth') + deriveGeneratedTabTitle( + 'Please fix auth note #1 with `src/auth.ts`!!! https://example.com 🔥' + ) + ).toBe('Fix auth note 1 with src auth') }) it('preserves non-ASCII title text while folding Unicode whitespace', () => { @@ -28,50 +30,24 @@ describe('deriveGeneratedTabTitle', () => { ) }) - it('leads with the issue identifier and keeps the description after it', () => { + it('keeps useful text after common issue prefixes', () => { expect(deriveGeneratedTabTitle('Issue #2056: Opt-in generated tab titles for agents')).toBe( - 'Issue 2056 - Opt in generated tab' + 'Opt in generated tab titles for agents' ) }) - it('recovers a PR number from a URL the pipeline strips, leading with it', () => { - expect( - deriveGeneratedTabTitle( - 'Review this community PR https://github.com/EveryInc/plugin/pull/1094' - ) - ).toBe('PR 1094 - Review this community') - }) - - it('leads with a GitLab MR identifier', () => { - expect( - deriveGeneratedTabTitle('fix https://gitlab.com/group/app/-/merge_requests/42 quickly') - ).toBe('MR 42 - Fix quickly') - }) - it('strips a URL containing underscores intact', () => { expect( deriveGeneratedTabTitle('inspect https://gitlab.com/g/p/-/work_items/9 then report') - ).toBe('Issue 9 - Inspect then report') + ).toBe('Inspect then report') }) - it('leads with the identifier for a URL wrapped in markdown emphasis, without leaking fragments', () => { + it('strips a URL wrapped in markdown emphasis without leaking fragments', () => { const title = deriveGeneratedTabTitle('Review _https://github.com/o/r/pull/5_ now') - expect(title).toBe('PR 5 - Review now') + expect(title).toBe('Review now') expect(title).not.toMatch(/https|pull/) }) - it('leads with a bare ticket key', () => { - expect(deriveGeneratedTabTitle('implement ENG-456 login flow')).toBe( - 'ENG-456 - Implement login flow' - ) - }) - - it('does not treat a cipher token as an identifier', () => { - expect(deriveGeneratedTabTitle('implement SHA-256 hashing in the signer')).toBe( - 'Implement SHA 256 hashing in the signer' - ) - }) - it('bounds titles to the maximum length without adding punctuation', () => { const title = deriveGeneratedTabTitle( 'I want to replace the terminal reconnection hydration flow with a safer retry path' diff --git a/src/shared/agent-tab-title.ts b/src/shared/agent-tab-title.ts index 2060409fbd7..76a36304267 100644 --- a/src/shared/agent-tab-title.ts +++ b/src/shared/agent-tab-title.ts @@ -1,9 +1,3 @@ -import { - extractWorkIdentifier, - formatIdentifierFirst, - stripWorkIdentifierEcho -} from './work-item-reference' - export const GENERATED_TAB_TITLE_MAX_LENGTH = 40 export const GENERATED_TAB_TITLE_SOURCE_SCAN_LIMIT = 512 @@ -109,18 +103,6 @@ export function deriveGeneratedTabTitle(prompt: string): string | null { candidate = foldGeneratedTabTitleWhitespace(candidate.replace(/[^\p{L}\p{N}\s]/gu, ' ')) - // Lead with the review target (`PR 1094 - …`) so the tab matches the sidebar - // workspace name; the pipeline above strips the URL/prefix, so the number is - // recovered from the raw prompt rather than the cleaned clause. - const identifier = extractWorkIdentifier(promptPreview) - if (identifier) { - const detail = capitalizeFirstLetter(stripWorkIdentifierEcho(candidate, identifier)) - return truncateAtWordBoundary( - formatIdentifierFirst(identifier.label, detail), - GENERATED_TAB_TITLE_MAX_LENGTH - ) - } - if (!candidate) { return null } diff --git a/src/shared/display-name-from-work.test.ts b/src/shared/display-name-from-work.test.ts deleted file mode 100644 index ea467e3ea3b..00000000000 --- a/src/shared/display-name-from-work.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { deriveWorkspaceDisplayName } from './display-name-from-work' - -// Identifier extraction itself is covered in work-item-reference.test.ts; these -// cases exercise the end-to-end display-name composition. -describe('deriveWorkspaceDisplayName', () => { - it('leads with the identifier and a single action verb', () => { - expect( - deriveWorkspaceDisplayName({ - prompt: 'Carefully evaluate https://github.com/o/r/pull/1033. Fix the merge conflict.', - slug: 'review-community-pr-conflict' - }) - ).toBe('PR 1033 - Review') - }) - - it('drops identifier tokens the slug also carried', () => { - expect( - deriveWorkspaceDisplayName({ - prompt: 'look at this community PR https://github.com/o/r/pull/1094', - slug: 'review-community-pr-1094' - }) - ).toBe('PR 1094 - Review') - }) - - it('uses a namespaced ticket id bare, without a type prefix', () => { - expect( - deriveWorkspaceDisplayName({ prompt: 'fix ENG-456 crash', slug: 'fix-eng-456-crash' }) - ).toBe('ENG-456 - Fix') - }) - - it('returns the identifier alone when no action word survives', () => { - expect(deriveWorkspaceDisplayName({ prompt: 'PR 12', slug: 'pr-12' })).toBe('PR 12') - }) - - it('carries a collision suffix so same-target worktrees stay distinct', () => { - expect( - deriveWorkspaceDisplayName({ - prompt: 'review https://github.com/o/r/pull/1033', - slug: 'review-conflict', - resolvedLeaf: 'review-conflict-2' - }) - ).toBe('PR 1033 - Review (2)') - }) - - it('falls back to the humanized leaf when no identifier is present', () => { - expect( - deriveWorkspaceDisplayName({ prompt: 'add a dark mode toggle', slug: 'add-dark-mode-toggle' }) - ).toBe('Add dark mode toggle') - }) - - it('humanizes the resolved leaf (with suffix) on the fallback path', () => { - expect( - deriveWorkspaceDisplayName({ - prompt: 'add a logout button', - slug: 'add-logout-button', - resolvedLeaf: 'add-logout-button-2' - }) - ).toBe('Add logout button 2') - }) -}) diff --git a/src/shared/display-name-from-work.ts b/src/shared/display-name-from-work.ts deleted file mode 100644 index 65889b8417c..00000000000 --- a/src/shared/display-name-from-work.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { humanizeBranchSlug } from './branch-name-from-work' -import { extractWorkIdentifier, formatIdentifierFirst } from './work-item-reference' - -// Why: the git branch must stay lowercase kebab-case, but the sidebar display -// name is free-form. When the first prompt references a review target, that -// identifier is the highest-signal, most searchable token — so we lead with it -// (`PR 1033 - Review`) instead of a paraphrased slug like "Review community pr -// 1033". The identifier comes from the raw prompt (via work-item-reference), -// where the number still exists, not from the lossy branch slug. - -// Type words that are never a useful action verb, skipped even if a slug leads -// with one (e.g. `pr-1094-review` → `Review`, not `Pr`). -const ACTION_STOPWORDS = new Set([ - 'pr', - 'mr', - 'pull', - 'merge', - 'request', - 'requests', - 'issue', - 'issues', - 'the', - 'a', - 'an', - 'to', - 'for', - 'of' -]) - -// The first slug word that is neither a digit nor an identifier/type token, -// capitalized — the task verb ("Review", "Fix"). '' when none remains. -function leadingActionWord(slug: string, identifierTokens: string[]): string { - const skip = new Set([...identifierTokens, ...ACTION_STOPWORDS]) - for (const word of slug.split('-').filter(Boolean)) { - if (/^\d+$/.test(word) || skip.has(word)) { - continue - } - return word.charAt(0).toUpperCase() + word.slice(1) - } - return '' -} - -// A `-N` collision suffix (`fix-auth` → `fix-auth-2`) that resolveUniqueBranchName -// appended, so two worktrees on the same identifier stay distinguishable. -function collisionSuffixFromLeaf( - baseSlug: string, - resolvedLeaf: string | undefined -): number | null { - if (!resolvedLeaf || resolvedLeaf === baseSlug || !resolvedLeaf.startsWith(`${baseSlug}-`)) { - return null - } - const rest = resolvedLeaf.slice(baseSlug.length + 1) - return /^\d+$/.test(rest) ? Number(rest) : null -} - -/** - * Build the sidebar display name for a freshly auto-renamed workspace. When the - * prompt names a review target, returns ` - ` (e.g. - * `PR 1033 - Review`); otherwise falls back to the plain humanized branch slug. - * `resolvedLeaf` is the final branch leaf (after any collision suffix); omit it - * for folder workspaces that have no git branch. - */ -export function deriveWorkspaceDisplayName(input: { - prompt: string - slug: string - resolvedLeaf?: string -}): string { - const identifier = extractWorkIdentifier(input.prompt) - if (!identifier) { - return humanizeBranchSlug(input.resolvedLeaf ?? input.slug) - } - const action = leadingActionWord(input.slug, identifier.tokens) - const base = formatIdentifierFirst(identifier.label, action) - const suffix = collisionSuffixFromLeaf(input.slug, input.resolvedLeaf) - return suffix ? `${base} (${suffix})` : base -} diff --git a/src/shared/work-item-reference.test.ts b/src/shared/work-item-reference.test.ts deleted file mode 100644 index d4c55501bf7..00000000000 --- a/src/shared/work-item-reference.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { extractWorkIdentifier, stripWorkIdentifierEcho } from './work-item-reference' - -describe('extractWorkIdentifier', () => { - it('reads a GitHub pull request URL', () => { - expect(extractWorkIdentifier('Review https://github.com/EveryInc/plugin/pull/1033')).toEqual({ - label: 'PR 1033', - tokens: ['pr', '1033'] - }) - }) - - it('reads a Bitbucket Cloud pull-requests URL', () => { - expect( - extractWorkIdentifier('Look at https://bitbucket.org/team/repo/pull-requests/77')?.label - ).toBe('PR 77') - }) - - it('reads a Bitbucket Server pull-requests URL', () => { - expect( - extractWorkIdentifier( - 'Review https://bitbucket.example.com/projects/ENG/repos/orca/pull-requests/1288' - ) - ).toEqual({ label: 'PR 1288', tokens: ['pr', '1288'] }) - // Personal (fork) repos live under /users instead of /projects. - expect( - extractWorkIdentifier( - 'see https://bitbucket.example.com/users/jane/repos/orca/pull-requests/9/overview' - )?.label - ).toBe('PR 9') - }) - - it('reads Azure DevOps pull request URLs (dev.azure.com and visualstudio.com)', () => { - expect( - extractWorkIdentifier('Look at https://dev.azure.com/contoso/Orca/_git/orca/pullrequest/4521') - ).toEqual({ label: 'PR 4521', tokens: ['pr', '4521'] }) - expect( - extractWorkIdentifier( - 'https://contoso.visualstudio.com/Orca/_git/orca/pullrequest/4521?_a=files' - )?.label - ).toBe('PR 4521') - }) - - it('reads a GitLab merge request URL as MR, and a work_items URL as an issue', () => { - expect(extractWorkIdentifier('Check https://gitlab.com/group/app/-/merge_requests/42')).toEqual( - { label: 'MR 42', tokens: ['mr', '42'] } - ) - expect(extractWorkIdentifier('https://gitlab.example.com/g/p/-/work_items/9')?.label).toBe( - 'Issue 9' - ) - }) - - it('reads an issue URL', () => { - expect(extractWorkIdentifier('Fix https://github.com/o/r/issues/88')?.label).toBe('Issue 88') - }) - - it('ignores URLs whose path only resembles a work item (no owner/repo, wrong host shape)', () => { - // A CDN asset path contains `/pull/2023` but is not a pull request. - expect( - extractWorkIdentifier('Load https://cdn.vendor.com/assets/pull/2023/data.json') - ).toBeNull() - // No trailing number after the item segment. - expect(extractWorkIdentifier('see https://github.com/o/r/pull/notanumber')).toBeNull() - }) - - it('tolerates trailing punctuation around a URL', () => { - expect(extractWorkIdentifier('(see https://github.com/o/r/pull/5).')?.label).toBe('PR 5') - }) - - it('reads a URL wrapped in markdown emphasis (trailing underscore/asterisk)', () => { - expect(extractWorkIdentifier('Review _https://github.com/o/r/pull/5_ now')?.label).toBe('PR 5') - expect(extractWorkIdentifier('Review **https://github.com/o/r/pull/1094**')?.label).toBe( - 'PR 1094' - ) - }) - - it('reads textual references', () => { - expect(extractWorkIdentifier('please review PR #1094')?.label).toBe('PR 1094') - expect(extractWorkIdentifier('triage pull request 500')?.label).toBe('PR 500') - expect(extractWorkIdentifier('reproduce issue 12')?.label).toBe('Issue 12') - expect(extractWorkIdentifier('handle merge request !9')?.label).toBe('MR 9') - }) - - it('reads a namespaced ticket id bare', () => { - expect(extractWorkIdentifier('implement ENG-456 login flow')).toEqual({ - label: 'ENG-456', - tokens: ['eng', '456'] - }) - }) - - it('does not treat standards, ciphers, or encodings as tickets', () => { - expect(extractWorkIdentifier('implement SHA-256 hashing')).toBeNull() - expect(extractWorkIdentifier('parse UTF-8 input')).toBeNull() - expect(extractWorkIdentifier('handle ISO-8601 dates')).toBeNull() - }) - - it('skips a denylisted prefix but still finds a real key after it', () => { - expect(extractWorkIdentifier('encrypt with AES-256 for ticket ENG-99')?.label).toBe('ENG-99') - }) - - it('prefers a provider URL over an incidental ticket-shaped token', () => { - expect( - extractWorkIdentifier('per RFC-2616 notes, review https://github.com/o/r/pull/7')?.label - ).toBe('PR 7') - }) - - it('falls back to a bare number, then to null', () => { - expect(extractWorkIdentifier('look at #321 when free')?.label).toBe('#321') - expect(extractWorkIdentifier('add a dark mode toggle to settings')).toBeNull() - }) -}) - -describe('stripWorkIdentifierEcho', () => { - it('removes the identifier tokens from a description', () => { - expect( - stripWorkIdentifierEcho('Review this community PR', { - label: 'PR 1094', - tokens: ['pr', '1094'] - }) - ).toBe('Review this community') - }) - - it('removes a ticket key echoed in the description', () => { - expect( - stripWorkIdentifierEcho('Fix ENG 456 crash', { label: 'ENG-456', tokens: ['eng', '456'] }) - ).toBe('Fix crash') - }) -}) diff --git a/src/shared/work-item-reference.ts b/src/shared/work-item-reference.ts deleted file mode 100644 index ccf1304b9b8..00000000000 --- a/src/shared/work-item-reference.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Why: a single, host-aware parser for the review target named in a prompt (a -// PR, MR, issue, or ticket), shared by the sidebar workspace name and the tab -// title so both surface the same identifier. URLs are validated by *path -// structure* (owner/repo/pull/N, GitLab's `/-/` marker) rather than hostname, -// which keeps GitHub Enterprise and self-hosted GitLab working while rejecting -// stray URLs that merely contain `/pull/` (CDN assets, docs pages). - -export type WorkIdentifier = { - /** Human label, identifier-first, e.g. `PR 1033`, `MR 42`, `ENG-456`. */ - label: string - /** Lowercased identifier tokens, so consumers can drop them from a slug or - * description rather than echoing `Pr`, a bare number, or the ticket twice. */ - tokens: string[] -} - -// Prompts can be paste-sized, and a review target is named up front — so bound -// the scan to a prefix rather than running regexes over the whole prompt. -const IDENTIFIER_SCAN_LIMIT = 4096 - -// Uppercase prefixes that look like Jira/Linear keys but are standards, ciphers, -// or encodings — kept off the ticket path so `SHA-256` / `UTF-8` / `ISO-8601` -// don't become workspace names. Single-letter prefixes (`P-256`) can't match the -// two-letter-minimum pattern, so they need no entry here. -const NON_TICKET_PREFIXES = new Set([ - 'UTF', - 'SHA', - 'MD', - 'ISO', - 'RFC', - 'AES', - 'RSA', - 'EC', - 'ES', - 'RS', - 'HS', - 'PS', - 'GPT', - 'MPEG', - 'UTC', - 'GMT', - 'IPV', - 'IEEE', - 'ANSI', - 'ASCII', - 'TLS', - 'SSL', - 'HTTP', - 'HTTPS' -]) - -const URL_IN_TEXT = /https?:\/\/[^\s<>()[\]"']+/gi -// GitLab's project-internal `/-/` marker is unambiguous; a GitLab issue path -// also would not match the GitHub pattern, so ordering GitLab first is safe. -const GITLAB_ITEM_PATH = /\/-\/(issues|work_items|merge_requests)\/(\d+)(?:[/?#]|$)/i -const GITHUB_ITEM_PATH = /^\/[^/]+\/[^/]+\/(issues|pull)\/(\d+)(?:[/?#]|$)/i -// Bitbucket Cloud: /workspace/repo/pull-requests/N -const BITBUCKET_CLOUD_ITEM_PATH = /^\/[^/]+\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i -// Bitbucket Server / Data Center nests the repo under a project or user, so the -// PR path carries more segments than Cloud: /projects/KEY/repos/REPO/pull-requests/N. -const BITBUCKET_SERVER_ITEM_PATH = - /\/(?:projects|users)\/[^/]+\/repos\/[^/]+\/pull-requests\/(\d+)(?:[/?#]|$)/i -// Azure DevOps (dev.azure.com, *.visualstudio.com, on-prem collections) always -// routes a PR through /_git/REPO/pullrequest/N, regardless of org/project prefix. -const AZURE_DEVOPS_ITEM_PATH = /\/_git\/[^/]+\/pullrequests?\/(\d+)(?:[/?#]|$)/i - -function taggedIdentifier(type: 'PR' | 'MR' | 'Issue', num: string): WorkIdentifier { - return { label: `${type} ${num}`, tokens: [type.toLowerCase(), num] } -} - -function urlToIdentifier(raw: string): WorkIdentifier | null { - let url: URL - try { - url = new URL(raw) - } catch { - return null - } - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - const path = url.pathname - const gitlab = GITLAB_ITEM_PATH.exec(path) - if (gitlab) { - return gitlab[1].toLowerCase() === 'merge_requests' - ? taggedIdentifier('MR', gitlab[2]) - : taggedIdentifier('Issue', gitlab[2]) - } - const github = GITHUB_ITEM_PATH.exec(path) - if (github) { - return github[1].toLowerCase() === 'pull' - ? taggedIdentifier('PR', github[2]) - : taggedIdentifier('Issue', github[2]) - } - const bitbucketCloud = BITBUCKET_CLOUD_ITEM_PATH.exec(path) - if (bitbucketCloud) { - return taggedIdentifier('PR', bitbucketCloud[1]) - } - const bitbucketServer = BITBUCKET_SERVER_ITEM_PATH.exec(path) - if (bitbucketServer) { - return taggedIdentifier('PR', bitbucketServer[1]) - } - const azureDevops = AZURE_DEVOPS_ITEM_PATH.exec(path) - if (azureDevops) { - return taggedIdentifier('PR', azureDevops[1]) - } - return null -} - -function findUrlIdentifier(text: string): WorkIdentifier | null { - const urls = text.match(URL_IN_TEXT) - if (!urls) { - return null - } - for (const raw of urls) { - // Trim trailing sentence punctuation and markdown emphasis (`_`/`*`/`~`): a - // URL wrapped like `_…/pull/5_` otherwise keeps the `_`, breaking the path - // anchor so the identifier is lost. Interior `_` (`merge_requests`) is kept. - const identifier = urlToIdentifier(raw.replace(/[.,;:!?*_~]+$/, '')) - if (identifier) { - return identifier - } - } - return null -} - -/** - * Pull the review-target identifier out of raw prompt text. Precedence runs from - * most reliable (provider URLs) to least (a bare `#123`), so a real URL wins over - * an incidental ticket-shaped token. Returns null when the prompt names none. - */ -export function extractWorkIdentifier(text: string): WorkIdentifier | null { - const scanned = text.slice(0, IDENTIFIER_SCAN_LIMIT) - - const urlIdentifier = findUrlIdentifier(scanned) - if (urlIdentifier) { - return urlIdentifier - } - - // Textual references ("pull request #12", "PR 12", "issue 88"). - let match = scanned.match(/\bmerge\s+request\s*[#!]?\s*(\d+)/i) - if (match) { - return taggedIdentifier('MR', match[1]) - } - match = scanned.match(/\bpull\s+request\s*#?\s*(\d+)/i) ?? scanned.match(/\bpr\s*#?\s*(\d+)/i) - if (match) { - return taggedIdentifier('PR', match[1]) - } - match = scanned.match(/\bissue\s*#?\s*(\d+)/i) - if (match) { - return taggedIdentifier('Issue', match[1]) - } - - // Namespaced ticket id (Jira/Linear), used bare. Uppercase-only so lowercase - // tokens like `gpt-4` don't match; skip standards/cipher prefixes, and keep - // scanning so a real key after one (e.g. `SHA-256 … ENG-456`) still resolves. - for (const ticket of scanned.matchAll(/\b([A-Z]{2,10})-(\d{1,7})\b/g)) { - if (!NON_TICKET_PREFIXES.has(ticket[1])) { - return { label: `${ticket[1]}-${ticket[2]}`, tokens: [ticket[1].toLowerCase(), ticket[2]] } - } - } - - // Bare `#123` as a last resort — identifier-first but provider-agnostic. - match = scanned.match(/(?:^|\s)#(\d+)\b/) - if (match) { - return { label: `#${match[1]}`, tokens: [match[1]] } - } - - return null -} - -/** - * Compose an identifier-first label — `PR 1033 - Review`, or just `PR 1033` when - * there is no trailing detail. The single source of the format shared by the - * sidebar name, tab title, and auto-rename name so they cannot drift apart. - */ -export function formatIdentifierFirst(label: string, detail: string): string { - return detail ? `${label} - ${detail}` : label -} - -/** - * Remove the identifier's own tokens from a description so a caller can prepend - * the label without echoing it — `PR 1094 - Review this PR` becomes - * `PR 1094 - Review this`. - */ -export function stripWorkIdentifierEcho(text: string, identifier: WorkIdentifier): string { - let stripped = text - for (const token of identifier.tokens) { - stripped = stripped.replace(new RegExp(`\\b${token}\\b`, 'gi'), ' ') - } - return stripped.replace(/\s+/g, ' ').trim() -} diff --git a/src/shared/workspace-name.test.ts b/src/shared/workspace-name.test.ts index 797d507d83c..10885fbf197 100644 --- a/src/shared/workspace-name.test.ts +++ b/src/shared/workspace-name.test.ts @@ -95,8 +95,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'Issue 2635 - Fix', - seedName: 'issue-2635-fix' + displayName: 'Fix Issue 2635', + seedName: 'fix-issue-2635' }) }) @@ -111,8 +111,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'PR 1234 - Review', - seedName: 'pr-1234-review' + displayName: 'Review PR 1234', + seedName: 'review-pr-1234' }) expect( getWorkspaceIntentName({ @@ -125,8 +125,8 @@ describe('getWorkspaceIntentName', () => { } }) ).toEqual({ - displayName: 'MR 77 - Fix', - seedName: 'mr-77-fix' + displayName: 'Fix MR 77', + seedName: 'fix-mr-77' }) }) diff --git a/src/shared/workspace-name.ts b/src/shared/workspace-name.ts index f98958160b9..7ba7629e350 100644 --- a/src/shared/workspace-name.ts +++ b/src/shared/workspace-name.ts @@ -2,7 +2,6 @@ import { collectCompactWorkspaceWords, foldWorkspaceNameWhitespaceToHyphen } from './workspace-name-text-scanner' -import { formatIdentifierFirst } from './work-item-reference' function normalizeApostrophes(input: string): string { return input.replace(/[‘’]/g, "'") @@ -215,10 +214,7 @@ export function getWorkspaceIntentName(args: { const action = detectIntentAction(sourceText) ?? defaultActionForWorkItem(item) const identity = workItemIdentity(item) if (action) { - // Identifier-first so the sidebar leads with the searchable token - // (`PR 1033 - Review`); the shared formatIdentifierFirst keeps first-create, - // auto-rename, and tab-title names on one format. - displayName = formatIdentifierFirst(identity, action) + displayName = `${action} ${identity}` } else { const subject = compactWorkItemTitle(item.title, item) displayName = [identity, subject].filter(Boolean).join(' ') From 2a32c5c9a1368a797677d71be8f027c73dc50ef3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 21 Jul 2026 20:23:09 -0700 Subject: [PATCH 2/2] fix(runtime): scope mirrored-editor close intent to its runtime environment (#9836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9804 added a leading `environmentId` parameter to recordWebSessionCloseIntent (and isWebSessionCloseIntentPending) and updated the web-runtime-session.ts callers, but missed the second caller in close-mirrored-editor-tab.ts, which still passed 3 args. This broke the web typecheck on main and, had it compiled, would have recorded the close intent under the wrong scope key (closeIntentScopeKey(environmentId, worktreeId)) — so the host snapshot could flash the just-closed mirrored tab back. Pass the already-validated runtimeEnvironmentId (the same value handed to closeWebRuntimeSessionTab immediately below) so the intent is scoped correctly. Update the test's isWebSessionCloseIntentPending assertion to the 4-arg form. Co-authored-by: Orca --- src/renderer/src/runtime/close-mirrored-editor-tab.test.ts | 4 +++- src/renderer/src/runtime/close-mirrored-editor-tab.ts | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts index f95591eb49c..bc4e045e477 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.test.ts @@ -46,7 +46,9 @@ describe('notifyHostOfMirroredEditorClose', () => { // host snapshot in the dynamic-import gap can't flash the old-path tab back. notifyHostOfMirroredEditorClose(buildState(), 'wt-1', 'file-1') - expect(isWebSessionCloseIntentPending('wt-1', toHostSessionTabId('host-tab-1'), now)).toBe(true) + expect( + isWebSessionCloseIntentPending('env-1', 'wt-1', toHostSessionTabId('host-tab-1'), now) + ).toBe(true) }) it('closes the mirrored editor tab on the host using the host tab id', async () => { diff --git a/src/renderer/src/runtime/close-mirrored-editor-tab.ts b/src/renderer/src/runtime/close-mirrored-editor-tab.ts index 1dbd1fbf3a4..1030a13c920 100644 --- a/src/renderer/src/runtime/close-mirrored-editor-tab.ts +++ b/src/renderer/src/runtime/close-mirrored-editor-tab.ts @@ -40,7 +40,12 @@ export function notifyHostOfMirroredEditorClose( } // Record the close intent SYNCHRONOUSLY so a host snapshot landing before the dynamic import below resolves can't // flash the old-path tab back. closeWebRuntimeSessionTab re-records it idempotently. - recordWebSessionCloseIntent(worktreeId, toHostSessionTabId(unifiedTab.id), Date.now()) + recordWebSessionCloseIntent( + runtimeEnvironmentId, + worktreeId, + toHostSessionTabId(unifiedTab.id), + Date.now() + ) // Dynamic import: this helper is imported by the editor slice during store creation, so importing // web-runtime-session eagerly imports the store back and trips cyclic init in full-suite import order. void import('./web-runtime-session').then(({ closeWebRuntimeSessionTab }) =>