Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
machine with no agent it opened a card that died on
`claude: command not found`. It now opens a shell — where the install command
from those links can be pasted — and says so before you press it.
- **The new worktree can be named in plain words.** The name field only ever
took a branch name: anything with a space in it was refused as invalid, so
naming the work meant inventing the hyphens yourself, and leaving the field
blank handed the branch a name like `calm-glacier` — which is fine until it
is the name on a pull request, and renaming a branch after the PR is open
closes the PR. Type what the work is instead — `fix the auth redirect` — and
the branch becomes `fix-the-auth-redirect`, shown under the field before
anything is created. A name git already accepts is still used exactly as
typed, and a blank field still auto-generates one.
- **A project tab comes back to the screen you left it on.** Stepping over to
another project and back always dropped you onto the first project's
terminals, whatever you had been reading there: its Settings, the pull request
Expand Down
38 changes: 17 additions & 21 deletions frontend/src/components/sidebar/WorktreeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { isValidBranchName } from "@/lib/git/branch-name"
import { toBranchName } from "@/lib/git/branch-name"
import { useSandboxChoice, type SandboxAnswer } from "@/lib/use-sandbox-choice"
import { cn, errorText } from "@/lib/utils"

Expand Down Expand Up @@ -110,10 +110,10 @@ function Group({ title, items, base, onSelect }: GroupProps) {
)
}

// WorktreeDialog collects a worktree name (blank = random adjective-noun) and a
// base picked from a searchable list — existing worktrees to resume, then local
// and remote branches (remote bases are fetched and tracked). It stays open on
// failure so git's error is readable in place.
// WorktreeDialog collects a worktree name (blank = random adjective-noun, plain
// words = a slug of them) and a base picked from a searchable list — existing
// worktrees to resume, then local and remote branches (remote bases are fetched
// and tracked). It stays open on failure so git's error is readable in place.
export function WorktreeDialog({
open,
onOpenChange,
Expand All @@ -138,8 +138,9 @@ export function WorktreeDialog({
const vis = filterBranches(branches, filter)
const flat = flatValues(vis)
const noMatches = branches !== null && flat.length === 0
const trimmed = name.trim()
const nameInvalid = trimmed !== "" && !isValidBranchName(trimmed)
// What the typed name creates: itself when git takes it, a slug of the first
// few words when it is a phrase, blank when nothing usable was typed.
const newBranch = toBranchName(name)
const isResume = base.startsWith("worktree:")

// Keep the selected base in view as it changes — the preselected current
Expand Down Expand Up @@ -208,7 +209,7 @@ export function WorktreeDialog({
move(-1)
} else if (event.key === "Enter") {
event.preventDefault()
if (!nameInvalid && base && !submitting) {
if (base && !submitting) {
void submit()
}
}
Expand All @@ -228,7 +229,7 @@ export function WorktreeDialog({
setSubmitting(true)
setSubmitError("")
try {
await onCreate(trimmed, id, group === "remote", sandbox.answer)
await onCreate(newBranch, id, group === "remote", sandbox.answer)
} catch (err) {
setSubmitError(errorText(err))
setSubmitting(false)
Expand All @@ -255,20 +256,15 @@ export function WorktreeDialog({
id="worktree-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Leave blank to auto-generate (e.g. swift-rabbit)"
placeholder="A branch name, or the task in plain words (e.g. fix the auth redirect)"
disabled={isResume}
aria-invalid={nameInvalid || undefined}
autoFocus
/>
{nameInvalid ? (
<span className="text-xs text-destructive">Invalid branch name</span>
) : (
<span className="font-mono text-xs text-muted-foreground">
{isResume
? "Opens the selected worktree"
: `Branch: ${trimmed || "<auto-generated>"}`}
</span>
)}
<span className="font-mono text-xs text-muted-foreground">
{isResume
? "Opens the selected worktree"
: `Branch: ${newBranch || "<auto-generated>"}`}
</span>
</div>

<div className="flex min-h-0 flex-col gap-1.5">
Expand Down Expand Up @@ -346,7 +342,7 @@ export function WorktreeDialog({

<DialogFooter>
<DialogClose render={<Button variant="ghost" />}>Cancel</DialogClose>
<Button onClick={() => void submit()} disabled={nameInvalid || !base || submitting}>
<Button onClick={() => void submit()} disabled={!base || submitting}>
{submitting ? "Creating…" : isResume ? "Open worktree" : "Create worktree"}
</Button>
</DialogFooter>
Expand Down
48 changes: 47 additions & 1 deletion frontend/src/lib/git/branch-name.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"
import { isValidBranchName } from "./branch-name"
import { isValidBranchName, toBranchName } from "./branch-name"

describe("isValidBranchName", () => {
const valid = ["main", "feat/x", "a-b_c.d", "v1.0", "wt/deep/nesting", "UPPER"]
Expand Down Expand Up @@ -35,3 +35,49 @@ describe("isValidBranchName", () => {
expect(isValidBranchName(name)).toBe(false)
})
})

describe("toBranchName", () => {
const kept = ["main", "feat/x", "a-b_c.d", "wt/deep/nesting", "!!!"]
it.each(kept)("keeps %j, which git already accepts", (name) => {
expect(toBranchName(name)).toBe(name)
})

it("trims before deciding", () => {
expect(toBranchName(" feat/x ")).toBe("feat/x")
})

it("slugifies a typed phrase", () => {
expect(toBranchName("Fix the auth redirect loop")).toBe("fix-the-auth-redirect-loop")
})

it("keeps letters that are not ASCII", () => {
expect(toBranchName("corrigir a sessão órfã")).toBe("corrigir-a-sessão-órfã")
})

it("stops at five words", () => {
expect(toBranchName("one two three four five six")).toBe("one-two-three-four-five")
})

it("stops before the character maximum once the minimum is met", () => {
expect(toBranchName("refactor the authentication middleware completely")).toBe(
"refactor-the-authentication-middleware",
)
})

it("grows past the character maximum while under the character minimum", () => {
expect(toBranchName(`go on ${"z".repeat(40)}`)).toBe(`go-on-${"z".repeat(34)}`)
})

it("clamps a word that alone runs past the maximum", () => {
expect(toBranchName(`${"z".repeat(50)} x`)).toBe("z".repeat(40))
})

it("leaves no trailing separator when the clamp lands on one", () => {
expect(toBranchName(`${"a".repeat(39)} b`)).toBe("a".repeat(39))
})

const nothing = ["", " ", "@", "..."]
it.each(nothing)("returns nothing for %j, so the caller auto-generates", (input) => {
expect(toBranchName(input)).toBe("")
})
})
49 changes: 49 additions & 0 deletions frontend/src/lib/git/branch-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,52 @@ export function isValidBranchName(name: string): boolean {
part !== "" && !part.startsWith(".") && !part.endsWith(".") && !part.endsWith(".lock"),
)
}

// A name typed as words is grown one word at a time: to at least a minimum, so
// a run of very short words still reads as a phrase, and stopped before a
// maximum, so a pasted sentence does not become the branch. Both bounds are
// counted in words and in characters — either one alone lets the other run away.
const MIN_WORDS = 2
const MAX_WORDS = 5
const MIN_CHARS = 10
const MAX_CHARS = 40

/**
* The branch a typed worktree name creates: what was typed when git already
* accepts it, a kebab-case slug of the first few words when it does not, and
* "" when nothing usable is left — which is the same blank the caller
* auto-generates a name for.
*
* The point is the name a branch carries into a pull request. A blank field
* takes the random adjective-noun, and renaming that branch after the PR is
* open closes the PR, so the only cheap moment to say what the work is called
* is before the worktree exists. Typing the task in plain words is that moment.
*/
export function toBranchName(input: string): string {
const trimmed = input.trim()
if (trimmed === "" || isValidBranchName(trimmed)) {
return trimmed
}
// Letters and numbers in any script, not just ASCII: git takes a non-ASCII
// branch name, and stripping the accents off a phrase would spell it wrong.
const words = trimmed
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
const picked: string[] = []
let length = 0
for (const word of words) {
const next = length === 0 ? word.length : length + 1 + word.length
const haveMinimum = picked.length >= MIN_WORDS && length >= MIN_CHARS
if (picked.length >= MAX_WORDS || (next > MAX_CHARS && haveMinimum)) {
break
}
picked.push(word)
length = next
}
// A single word longer than the maximum is picked before the bound applies —
// it is that or nothing — so the clamp is enforced here, on the joined name.
return picked.join("-").slice(0, MAX_CHARS).replace(/-+$/, "")
}