Files
orca/src/shared/branch-name-from-work.ts
T
Trevin ChowandJinjing 7361bff698 feat: rename worktree folder to match branch on first work (#4743)
* feat: rename worktree folder to match branch on first work

When the first agent message auto-renames a freshly created creature branch to a
short, work-derived name, also align the on-disk worktree folder and the sidebar
display name with it. Re-key every worktree-scoped slice of state — renderer
store maps plus the persisted main-process state — through the resulting id
change so the live worktree survives the rename instead of being treated as a
deletion (its tabs, terminals, browser panes, and git status all follow).

The rename is best-effort and local-only: a skip or failure (remote runtime,
Windows lock, destination taken) leaves the folder as-is and never undoes the
branch/display rename that already landed.

Squashed from the original PR #4743 commits, rebased onto upstream/main to drop
accumulated merge commits and i18n formatting churn so the branch carries only
the feature diff:
- Rename worktree folder to match branch on first work
- migrate renamed worktree session ids
- keep the live worktree alive through a folder rename
- address PR review feedback

* Address PR review feedback (#4743)

- orca-runtime: emit in-process worktreesChanged client event on folder rename, mirroring notifyBranchRenamed so onClientEvent listeners aren't left stale
- worktrees: re-key rightSidebarExplorerViewByWorktree and activeWorkspaceKey through a worktree-identity rename (both were worktree-scoped but missed by buildWorktreeRenameState)
- branch-name-from-work: treat prefix-only model output as an empty slug so the caller skips the rename instead of producing a doubled prefix
- worktree-folder-rename-target: document why posix.dirname is safe (Windows filtered out earlier)
- tests for each of the above

* Extract branch rename test helpers to a separate harness file

Move git responders, mock builders, and test event fixtures out of
first-work-branch-rename.test.ts into a new test harness file. This
reduces file length and removes the max-lines ESLint disable directive,
complying with project style guidelines.

* Wrap entire OnboardingFlow in TooltipProvider

Enable the use of tooltips anywhere within the onboarding flow, rather
than restricting them to the step indicators.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-06-15 19:58:55 -07:00

130 lines
4.6 KiB
TypeScript

import { MARINE_CREATURES } from './marine-creatures'
// Why: the work-derived branch name stays short on purpose — long, sentence-like
// branches read worse than the creature name they replace. Two-to-four words is
// the sweet spot the feature targets.
export const MAX_BRANCH_NAME_WORDS = 4
const MIN_BRANCH_NAME_WORDS = 2
const CREATURE_NAMES_LOWER = new Set(MARINE_CREATURES.map((name) => name.toLowerCase()))
function normalizeName(name: string): string {
return name.trim().toLowerCase()
}
/**
* True when `branchLeaf` (the segment after any `prefix/`) is an Orca
* auto-generated creature name, e.g. `Nautilus` or `Octopus-2`. Used as the
* eligibility gate so auto-rename only ever overwrites a name Orca itself
* picked — never a branch the user named deliberately.
*/
export function isAutoGeneratedCreatureBranchName(branchLeaf: string): boolean {
const normalized = normalizeName(branchLeaf)
if (!normalized) {
return false
}
// Collision suffixes (`-2`, `-3`, …) are appended at creation; strip one
// before checking membership so numbered creatures still qualify.
const base = normalized.replace(/-\d+$/, '')
return CREATURE_NAMES_LOWER.has(base)
}
/**
* Turn raw model output into a safe, short kebab-case branch leaf. Returns ''
* when nothing usable remains, which callers treat as "skip the rename".
*/
export function sanitizeBranchSlug(raw: string, maxWords = MAX_BRANCH_NAME_WORDS): string {
const words = raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.split('-')
.filter(Boolean)
if (words.length === 0) {
return ''
}
return words.slice(0, maxWords).join('-')
}
/**
* Drop a leading prefix segment the generation model prepended despite the
* "no prefixes" instruction — e.g. with a `tmchow/` branch prefix the model
* emits `tmchow/worktree-spinner`, which `sanitizeBranchSlug` folds to
* `tmchow-worktree-spinner`. Left alone, that leaks the prefix into both the
* branch leaf (yielding a doubled `tmchow/tmchow-...`) and the humanized
* display name. Strips only when the leading segment matches the *configured*
* prefix, so a work-derived name that merely starts with a real word survives.
* Prefix-only output (the model echoed just `tmchow`) yields an empty slug so
* the caller skips the rename — otherwise it would double-prefix to
* `tmchow/tmchow`.
*/
export function stripConfiguredBranchPrefix(
slug: string,
prefix: string | null | undefined
): string {
if (!prefix) {
return slug
}
const prefixSlug = prefix
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
if (!prefixSlug) {
return slug
}
if (slug === prefixSlug) {
return ''
}
return slug.startsWith(`${prefixSlug}-`) ? slug.slice(prefixSlug.length + 1) : slug
}
/**
* Turn a branch slug into a readable workspace display label, e.g.
* `supported-models-list` → `Supported models list`. Used to update the
* sidebar name alongside the branch so the two stay in sync.
*/
export function humanizeBranchSlug(slug: string): string {
const joined = slug.split('-').filter(Boolean).join(' ')
if (!joined) {
return ''
}
return joined.charAt(0).toUpperCase() + joined.slice(1)
}
export type BranchNameWorkContext = {
/** The user's first prompt to the agent in this workspace. */
firstPrompt: string
/** The agent's first response, when it has already arrived. Optional because
* the rename fires as soon as work begins, before a reply may exist. */
assistantMessage?: string
}
/**
* Build the text-generation prompt that asks the configured agent to summarize
* the work into a branch name. Kept in shared so the prompt is identical across
* local and SSH generation targets.
*/
export function buildBranchNamePrompt(context: BranchNameWorkContext, customPrompt = ''): string {
const sections = [
'Generate a git branch name that summarizes the coding task described below.',
'Rules:',
`- Use between ${MIN_BRANCH_NAME_WORDS} and ${MAX_BRANCH_NAME_WORDS} words.`,
'- Lowercase kebab-case only (words joined by single hyphens).',
'- No slashes, no prefixes, no quotes, no trailing punctuation.',
'- Describe the work itself, not the agent or the repository.',
'- Output ONLY the branch name on a single line, nothing else.',
'',
'User request:',
context.firstPrompt.trim()
]
const assistant = context.assistantMessage?.trim()
if (assistant) {
sections.push('', "Agent's initial response:", assistant)
}
const prompt = customPrompt.trim()
if (prompt) {
sections.push('', 'Additional user prompt:', prompt)
}
return sections.join('\n')
}