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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,11 @@ Styles in `styles.go` are declared **bare** and constructed **only** in `ApplyPa
- **Nothing polls, and a prompt is seeded exactly once.** Ticket work is event-driven and one-shot: it happens at **worktree creation and nowhere else** (`w` + a ticket, `fleet worktree --ticket`), so a session added by hand with `a`/`n`/`A` starts empty like every other manually created session. `handleSessionCreate` therefore does no inference at all — it passes `msg.prompt` through untouched. fleet used to infer a ticket from the branch on *every* session creation, plus reuse the already-written `prompt.txt` when `.fleet/ticket/<ID>/` was present. Both re-asked the original task on every session after the first, and the reuse branch never looked at the branch at all, so a worktree that once held a ticket kept seeding it after the checkout had moved on to `master`. A seeded first message is the *gesture* of starting a worktree from a ticket, not a property of the directory. `TestTicketWorkStaysOffTheWorkers` keeps it out of `refreshAllGitAndPR`, whose `workerStallThreshold` (90s) is already sized against ~70s of git + `gh` per repo. This is only affordable because there is no badge, hence no live state to keep fresh.
- The one mutation resolves the team's started state by **type**, against a position-sorted list (`TestStartedStateResolvesByTypeAndPosition`), so it works on a team whose started state is called "In Dev" or "Doing". Position matters as much as type: a real team has several started states (In Progress at position 2, In Review at 1002) and the lowest is what a human means by "I'm starting this" — any other choice would move a fresh ticket straight to review. Fires **only on create-from-ticket** (config `linear_ticket_start`, default true), never when a later session opens in an existing worktree — by then a human may have moved the issue on, and dragging it backwards is the worst thing this could do. `meta.json` records `state_write` so it stays exactly-once.
- The seeded prompt is a **short pointer that tells the agent not to start** (`TestSeedPromptTellsAgentNotToStart`), stated at the top and bottom because a first message describing a task reads as an instruction to perform it. Line 1 leads with the identifier before the title because it is three surfaces at once: the agent's instruction, the preview pane's prompt strip, and the input to `naming.GenerateTitle`, which cuts at ~50 runes. It rides `sessionCreateMsg.prompt` -> `Session.InitialPrompt`.
- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-<slug>` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The confirmation line reads **`✓ named from BRZ-3217 · <title>`**, not a bare identifier: it renders in the same place the selectable ticket rows do, so on its own it read as a row you might still need to arrow onto when the naming had already happened — and it is the only thing on screen that explains why the field rewrote itself a moment earlier. The wording holds in both states `d.resolved` can be in, including a tail you typed yourself, because `onFieldChanged` drops the resolution the moment the text stops leading with the identifier. `✓` is U+2713, East-Asian-Neutral, so it is always one column — the same width check the priority gauge had to pass. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter`, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves.
- **Base branch autocomplete** (`internal/ui/workspace_picker_branch.go`, issue #277): the `w` dialog's Base branch field suggests branches beneath itself, on the ticket rows' vocabulary (`▸` + `selTitle`, dim otherwise, `branchMaxRows`=5) and through the same `setSelection` highlight. The list is fetched **once**, by `fetchWorkspaceListForRepo` on the worker goroutine beside the `GetDefaultBranch` it already ran, and rides `workspaceListMsg.branches` — so filtering is a synchronous `strings.Contains`, with **none** of the ticket machinery's debounce or generation guard, which exist only because a lookup is a network round trip. A failed listing leaves the field exactly as it was. While that field carries the highlight the **existing-worktrees list is hidden**, which is a height budget rather than a flourish: the dialog has none of its own (`wrapDialog` only `Place`s, and the worktree loop is unbounded), so at 80×24 with six worktrees five suggestion rows took the box from 21 lines to 26 and pushed the footer off the bottom. Dropping the list reclaims more than the rows add, so the focused dialog is strictly *shorter* than the resting one (`TestWorktreeFocusedDialogIsNoTallerThanAtRest`; `TestWorktreeDialogRowsNeverOverflow` is width-only and runs at height 40, so it could never catch this). Capping the rows against remaining height was the alternative and was rejected: `setSelection` runs nowhere near `View`, so the cursor clamp would have started varying with window size. Hidden is not unreachable — `shift+tab` still cycles onto the list and brings it back. Rows render **only while that field carries the highlight**: the field always holds text, so rendering them unconditionally would park five rows mid-dialog for every user including everyone who never touches the base branch — deliberately unlike `renderTicketBlock`, which renders whenever tickets exist because prose in the *other* field is what put them there.
- **`tab` moves between fields, `↓`/`↑` walk rows** — split apart for this. They were one merged case, which was fine while only the New branch field grew rows and stopped being fine the moment the Base branch field grew its own: tabbing off a focused base field would have taken six presses to reach the next input. The `↓`/`↑` walk is the full continuous path (base input → base rows → new-branch input → ticket rows → worktree list) and must retrace itself exactly — which is why `↑` from the New branch field lands on the *last* base row (`visibleBranchCount()-1`), not the base input. It **cycles**: `tab` wraps the worktree list back to the base field and `shift+tab` wraps the other way, because a key documented as "next field" that does nothing on the *last* field is a dead key rather than a design, and wrapping one direction only moves the dead end onto the other. Cost: `tab` no longer walks the worktree list; `↓` still does. `visibleBranchCount()` is deliberately **not** focus-gated even though `renderBranchBlock` is — that `↑` handler asks for the last row while focus is still elsewhere, and a focus gate would answer 0 and silently land on the input.
- **A suggestion is stored as the exact ref that will be written into the field**, which is why a remote-only branch always comes out `origin/`-prefixed (`baseRefFor`). `GitWorktreeProvider.Create` runs `git worktree add <path> -b <new> <base>`, and with `-b` present git resolves `<base>` as a plain revision with **no** remote-tracking DWIM — so a bare remote-only name is a row that looks valid and fails on Enter, and the no-`-b` retry silently drops the base entirely. A field already reading `origin/…` keeps its prefix and matches only branches with a remote (`git.BranchInfo.HasRemote`, added for this): `GetDefaultBranch` pre-fills `origin/<default>` precisely so a worktree starts from the remote tip, and quietly swapping that for the local branch would change what gets built without saying so. `HasRemote` forced `ListBranches` into two passes — a branch shares its committer date with its `origin/` counterpart only when it is *level*, so a branch you have not pulled has its remote ref sort **first**, which the old single-pass form both missed the flag on and emitted twice (`TestListBranchesRemoteCounterparts`; the duplicate was already visible in the `b` key's picker).
- **An at-rest field lists the alternatives, not itself.** Filtering the pre-filled `origin/<default>` yields exactly one row echoing the field — a row that answers nothing and no-ops on Enter — so a field nobody is typing into widens to the unfiltered list instead, keeping `wantRemote` (you asked for a remote ref, so the wider list is the other remote refs) and **excluding the field's own value**, which would otherwise reinstate the very no-op row the widening removes. That exclusion runs *before* the `branchMaxRows` cap: filtering `matchBranches`' result instead silently returns one row fewer than fits. The gate is `baseAtRest` — set by `Show` and `pickBaseBranch`, cleared by the first typed change — and **not** equality between the field and a branch name, which looks equivalent and is not: typing your way to `master-fix` passes *through* `master`, so the equality form widened 1 row to 5 at the `r` and collapsed back at the `-`, and since the dialog is vertically centred that was the whole box jumping four rows mid-word.
- **Ticket suggestions live in the `w` dialog's existing New branch field, not a new field and not a mode** (`internal/ui/workspace_picker_ticket.go`). The field IS the literal option, so nothing duplicates it and only one thing ever claims Enter. Two rules make that hold: **exactly one highlight, and the caret lives with it** — arrowing onto a ticket blurs the input, typing returns both and keeps the keystroke (`isTypingKey`, borrowed from the snooze dialog, whose "the highlight is the promise" rule this follows); and **shape decides the default, never a mode** — text matching a team's identifier shape resolves *in place* (`LooksLikeIdentifier`), prose stays literal with tickets one down-arrow below. *In place* means the field **becomes the branch name** (`applyResolvedBranchName` → `BranchNameFor`) while the highlight stays put; it does **not** mean "only record the ticket". That half was missing at first, so typing `BRZ-3217` fetched the ticket, materialized it and named the session after it — and then created a git worktree literally called `BRZ-3217`, breaking the invariant `pickTicket`'s own comment states, that both ways of naming a ticket end up identical. The rewrite is gated on the field still holding **nothing but that identifier**: the generation counter drops a reply a later keystroke invalidated, but it cannot see a *current* reply for a shorter identifier you paused on en route (`BRZ-321` while typing `BRZ-3217`), which would otherwise drop `brz-321-<slug>` under the cursor and let the rest of the typing land on the end of it. The same check leaves a tail you edited by hand alone. The confirmation line reads **`✓ named from BRZ-3217 · <title>`**, not a bare identifier: it renders in the same place the selectable ticket rows do, so on its own it read as a row you might still need to arrow onto when the naming had already happened — and it is the only thing on screen that explains why the field rewrote itself a moment earlier. The wording holds in both states `d.resolved` can be in, including a tail you typed yourself, because `onFieldChanged` drops the resolution the moment the text stops leading with the identifier. `✓` is U+2713, East-Asian-Neutral, so it is always one column — the same width check the priority gauge had to pass. The highlight never moves on its own; a picker that jumps its own selection is the ambiguity coming back through the window. `setSelection` is the single writer of `focus`/`ticketCursor`/`baseCursor`, enforced by `TestWorktreeSelectionMutatorIsTheOnlyWriter` across all three picker files, because a stray write skips the clamp and renders two selection markers. The footer names what Enter will do and changes as the highlight moves.
- Lookups are **debounced (250ms) and generation-guarded**, never per keystroke. Without the generation counter, typing `BRZ-3182` then editing to `BRZ-3184` lets the slower first reply overwrite the field with the wrong ticket's branch name, which then becomes a real git branch (`TestWorktreeStaleTicketReplyIgnored`). The counter is **monotonic and never reset to 0**, unlike `ConfirmDialog.scanGen` — that one lives on `Home` and never recycles; a per-dialog counter reset to zero would let a reply from a previous open match a new one. `d.loading` is deliberately not used for ticket lookups: it swallows every key but `esc`.
- Degradation is structural: no credential, no team, rejected key, offline, unknown id, or nothing matching — every case leaves Enter working and the dialog usable (`TestWorktreeEnterAlwaysCreates`). `ErrNotConnected` is the resting state for everyone who never connected and is **never surfaced as an error**; `ErrNotAuthenticated` is, because it names a thing to fix. Persistent failures latch (`ticketsOff`) so a broken credential isn't re-spent on every pause. Once the worktree exists nothing may fail the caller: `Materialize` sits beside `copyClaudeSettingsFile` and `CopyConfiguredFiles` and shares their posture, and a failed fetch costs the prompt, never the session.
- Connect dialog (`internal/ui/connect_linear.go`): a two-row method chooser, then a browser round trip or a **masked** input. A pasted key is **verified before it is stored** (one `viewer`/`organization`/`teams` query), so "connected" is a fact rather than a hope and a typo is caught while the user is still looking at the field they typed it into. It then names the workspace's team keys and shows the exact `.fleet.local.json` line that turns a repo on — the per-repo step is the one people miss, and `.local` is the right file because team membership is personal and `.fleet.json` is committed. Discovery is a `tipOnce` firing when a session's branch matches the generic `<team>-<number>` shape **and** nothing is connected — deliberately *not* `IdentifierFromBranch`, which is gated on team keys nobody has configured yet at the moment the tip should fire; a false positive costs one dismissible hint, since the tip only offers and never fetches.
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/base-branch-autocomplete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: improved
---

**Base branch autocomplete** — the `w` dialog's Base branch field now suggests your branches. `↓` walks them, `⏎` picks one, `tab` moves to the next field.
78 changes: 57 additions & 21 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -57,6 +58,7 @@ type BranchInfo struct {
Name string
IsRemote bool // only exists as remote (no local)
IsCurrent bool
HasRemote bool // an origin/<Name> ref exists (implied by IsRemote)
CommitDate time.Time
AuthorEmail string // email of the last commit's author
}
Expand All @@ -75,8 +77,23 @@ func ListBranches(repoPath string) ([]BranchInfo, error) {

currentBranch := GetBranchName(repoPath)

// Parsed in one pass, emitted in a second. The refs arrive sorted by commit
// date, and a branch shares its date with its origin/ counterpart, so the two
// can arrive in either order. Deciding inline could only ever see a
// counterpart that happened to sort first — which both missed HasRemote and
// emitted the branch twice.
type parsedRef struct {
name string
remote bool
commitDate time.Time
authorEmail string
}
var refs []parsedRef
localSet := make(map[string]bool)
var branches []BranchInfo
remoteSet := make(map[string]bool)
// The newest date seen for a name across BOTH namespaces — see the merge
// below for why the surviving ref cannot simply keep its own.
newest := make(map[string]time.Time)

lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, line := range lines {
Expand All @@ -96,33 +113,52 @@ func ListBranches(repoPath string) ([]BranchInfo, error) {
authorEmail = strings.Trim(parts[2], "<>")
}

if strings.HasPrefix(name, "origin/") {
remoteName := strings.TrimPrefix(name, "origin/")
if remoteName == "HEAD" {
remote := strings.HasPrefix(name, "origin/")
if remote {
name = strings.TrimPrefix(name, "origin/")
if name == "HEAD" {
continue
}
if localSet[remoteName] {
continue // already have local version
}
branches = append(branches, BranchInfo{
Name: remoteName,
IsRemote: true,
IsCurrent: remoteName == currentBranch,
CommitDate: commitDate,
AuthorEmail: authorEmail,
})
remoteSet[name] = true
} else {
localSet[name] = true
branches = append(branches, BranchInfo{
Name: name,
IsRemote: false,
IsCurrent: name == currentBranch,
CommitDate: commitDate,
AuthorEmail: authorEmail,
})
}
if commitDate.After(newest[name]) {
newest[name] = commitDate
}
refs = append(refs, parsedRef{name: name, remote: remote, commitDate: commitDate, authorEmail: authorEmail})
}

var branches []BranchInfo
for _, r := range refs {
if r.remote && localSet[r.name] {
continue // already have local version
}
branches = append(branches, BranchInfo{
Name: r.name,
IsRemote: r.remote,
IsCurrent: r.name == currentBranch,
HasRemote: remoteSet[r.name],
// The LATER of the two refs, not the survivor's own date. The
// dedupe keeps the local ref, but a branch you have not pulled has
// a local tip older than origin/<name> — and callers that ask for
// the remote form (the worktree dialog's base-branch suggestions do,
// via baseRefFor) would then resolve a commit this date does not
// describe. It is also what the branch sorts on, so an unpulled
// master would sink below fresher topic branches and fall out of any
// top-N list.
CommitDate: newest[r.name],
AuthorEmail: r.authorEmail,
})
}

// Re-sorted here rather than trusting for-each-ref's order: revising a date
// above invalidates it, and a stable sort keeps the ref order for genuine
// ties.
sort.SliceStable(branches, func(i, j int) bool {
return branches[i].CommitDate.After(branches[j].CommitDate)
})

// Move current branch to index 0.
for i, b := range branches {
if b.IsCurrent && i > 0 {
Expand Down
Loading
Loading