Ref picker: default branch not selectable in the list; type-select becomes Branches/Tags pills #482

Closed
opened 2026-09-13 19:21:47 +00:00 by crueber · 3 comments
Owner

Ref picker: default branch missing from the list + type-select becomes Branches/Tags pills

Two related gaps in the branch/tag ref dropdown (the main @ 1ca4c5b4 header button popover, RefPicker in web/src/pages/Repo.jsx), reported together by the user from one screenshot. Evidence is a static read of the freshly pulled tree (e9ea1f0) - no runtime repro (standing rule).

Part 1: the default branch is not selectable in the list

The picker's header pill shows main @ <sha>, but main never appears as a pickable row in the dropdown.

Mechanism (static analysis):

  • The list is fed by the SSE ref stream: repo.refStream(getKind(), { q: getQuery(), n: 50 }, ...) (Repo.jsx:241) against GET …/api/refs/branches (internal/api/refs.go refsList).
  • The server folds the WAL snapshot and pages refs name-sorted with a limit: RefList (internal/api/bind_wal.go:360) iterates snap.Refs (sorted by name), applies the q/after filters, and stops at n (client sends n: 50). There is no default-branch filter - refs/heads/main IS in the snapshot and IS returned by the endpoint.
  • The trap is ordering + paging: on this repo, 114 of 115 heads are fix/… (111), feat/… (2) and docs/… (1), and refs/heads/fix/… < refs/heads/main alphabetically. main sits at position ~115 of the name-sorted list - far past the single 50-ref page the client requests.
  • The client renders only what the stream emits and ignores pagination: the terminal event: done {"more": true} frame (internal/api/refs.go streamRefs, SDK repo.js _consume) is dropped - RefPicker has no after-based follow-up request. So the default branch is structurally unreachable for any repo whose default sorts late (any repo with 50+ branches named a…m-ish, including walhub itself).

This is the same class as the skill-noted "default is filtered as 'the current one'" hypothesis, but the actual filter is the 50-ref page window, not an explicit exclusion.

Fix: pin the default branch at the top of the branch list, client-side, from data the picker already has.

  • Repo.jsx already passes the summary head into the picker: head={() => pillHead(getViewed(), s().head)} (Repo.jsx:604). The summary head is the default-branch target (internal/api/refs.go refsHead → Head() → snap.HeadTarget), i.e. {name: "refs/heads/main", sha}. No wire change, no new endpoint.
  • In RefPicker, synthesize a pinned row from the summary head when getKind() === "branches" and head() carries a refs/heads/ name:
    • Render it as the FIRST row of .ref-list, above the streamed alphabetical list, visually separated (a subtle divider or a one-line "default" group label - implementer's call within the app's card/pill idiom).
    • Badge it: the branch name plus a small default tag/badge (the app's .pill chip idiom, web/src/ui.css:85) so it reads as pinned, not just first.
    • Dedupe: once the streamed page does include the default branch (small repos), drop the duplicate from the streamed list rather than rendering it twice.
    • Clicking the pinned row uses the same pick() path (/{full}/tree/{shortRef(name)}).
  • Optional server-side nicety (planner's call, not required): a ?pin=<refname> param on refs/{branches|tags} that hoists that ref to the front of the page. The client-side pin alone satisfies the acceptance criteria; do not block on the wire change.

Part 2: replace the type-select dropdown with two pills above the filter input

Today the popover's first row is a <select class="input"> (branches / tags) sitting beside the filter input (Repo.jsx:293-311, .ref-controls flex row). Replace it with a two-segment pill toggle, filter input below:

[ Branches | Tags ]      <- two toggle buttons, segmented-control look
[ filter refs…        ]  <- filter input, full width, stays below the pills
[ ref rows …           ]
  • Idiom: reuse the repo's button-pill pattern from #447/#465 - two btn px-2 py-1 text-sm buttons with classList={{ primary: getKind() === "branches" }} / … === "tags" } (same primary active-state treatment as WatchToggle/StarToggle), joined as a segmented pair (shared border radius, divider between). Opaque popover rules per #405 apply (the popover already carries background: var(--panel) in .ref-drop - do not regress it).
  • Accessibility: each pill is a real <button type="button"> with aria-pressed; the group gets role="group" + aria-label="Ref type". Keyboard order becomes pill 1 -> pill 2 -> filter input -> list (native Tab walk); the existing Esc handler (Repo.jsx:265-271, #255 outside-click close) is untouched.
  • Clicking a pill sets setKind(...), clears setRefs([]) and re-runs stream.run() - exactly the current <select> onChange behavior. The filter query persists across the switch (current contract: getQuery() is untouched on kind change) - keep that; the user's typed filter follows them between Branches and Tags.
  • Layout: .ref-controls becomes a column context - pills row on top, input below (flex-col, or a new wrapper). Keep gap-2 spacing consistent with the header gap conventions (#463).

Acceptance criteria

  • The default branch appears as a selectable, pinned, default-badged first row of the branch list in every repo, including repos where HEAD targets a non-main name and repos with 50+ alphabetically-earlier branches (walhub itself is the live test case: 115 heads, main at ~115)
  • Pinned row dedupes against the streamed list when the page happens to contain the default branch (no double render)
  • Pinned row click navigates to /{full}/tree/{default-branch} via the existing pick() path
  • The branches/tags <select> is gone; two toggle pills (Branches | Tags) sit above the filter input, active state via the btn … primary idiom
  • Pills swap the list source; the filter input persists its query across the switch; filtering still works per type
  • Keyboard navigation preserved: Tab reaches pills -> input -> rows; Esc dismisses and refocuses the trigger; outside-click closes (#255 conventions)
  • Popover stays opaque in light + dark (#405; .ref-drop keeps its var(--panel) background)
  • Mobile 390px: the two pills + input fit the popover width (pills row may sit side by side at 320px popover width; the #278 max-width: calc(100vw - 1rem) bound on .ref-drop in web/src/ui.css:204 is preserved)
  • Headless tests updated: web/test/unit/ref-picker-pill.test.js currently pins <option value="branches">branches</option> / <option value="tags">tags</option> - replace with pill-toggle assertions; add a default-branch-present assertion (pinned row derived from the summary head, dedupe rule) as a headless pure-function test in web/src/lib/ (the ref-pill.js convention) if the pin derivation is extracted

References

  • RefPicker: web/src/pages/Repo.jsx:224-330 (select block 293-311, stream 240-243, pick 255-260)
  • Server list: internal/api/bind_wal.go:360 (RefList, name-sorted + n cap), internal/api/refs.go:125 (refsList, SSE dialect), streamRefs done {more} frame the client ignores
  • Prior fixes: #255 (outside-click close), #278 (popover viewport bound), #405 (opaque popovers), #447/#465 (btn pill idiom + primary active state), #252 (pill head context-first)
# Ref picker: default branch missing from the list + type-select becomes Branches/Tags pills Two related gaps in the branch/tag ref dropdown (the `main @ 1ca4c5b4` header button popover, `RefPicker` in `web/src/pages/Repo.jsx`), reported together by the user from one screenshot. Evidence is a static read of the freshly pulled tree (e9ea1f0) - no runtime repro (standing rule). ## Part 1: the default branch is not selectable in the list The picker's header pill shows `main @ <sha>`, but `main` never appears as a pickable row in the dropdown. **Mechanism (static analysis):** - The list is fed by the SSE ref stream: `repo.refStream(getKind(), { q: getQuery(), n: 50 }, ...)` (Repo.jsx:241) against `GET …/api/refs/branches` (`internal/api/refs.go` `refsList`). - The server folds the WAL snapshot and pages refs **name-sorted** with a limit: `RefList` (`internal/api/bind_wal.go:360`) iterates `snap.Refs` (sorted by name), applies the `q`/`after` filters, and stops at `n` (client sends `n: 50`). There is no default-branch filter - `refs/heads/main` IS in the snapshot and IS returned by the endpoint. - The trap is ordering + paging: on this repo, 114 of 115 heads are `fix/…` (111), `feat/…` (2) and `docs/…` (1), and `refs/heads/fix/…` < `refs/heads/main` alphabetically. `main` sits at position ~115 of the name-sorted list - far past the single 50-ref page the client requests. - The client renders only what the stream emits and ignores pagination: the terminal `event: done {"more": true}` frame (`internal/api/refs.go` `streamRefs`, SDK `repo.js` `_consume`) is dropped - `RefPicker` has no `after`-based follow-up request. So the default branch is *structurally* unreachable for any repo whose default sorts late (any repo with 50+ branches named `a…m`-ish, including walhub itself). This is the same class as the skill-noted "default is filtered as 'the current one'" hypothesis, but the actual filter is the 50-ref page window, not an explicit exclusion. **Fix: pin the default branch at the top of the branch list, client-side, from data the picker already has.** - `Repo.jsx` already passes the summary head into the picker: `head={() => pillHead(getViewed(), s().head)}` (Repo.jsx:604). The summary `head` is the default-branch target (`internal/api/refs.go` `refsHead` → `Head()` → `snap.HeadTarget`), i.e. `{name: "refs/heads/main", sha}`. No wire change, no new endpoint. - In `RefPicker`, synthesize a pinned row from the summary head when `getKind() === "branches"` and `head()` carries a `refs/heads/` name: - Render it as the FIRST row of `.ref-list`, above the streamed alphabetical list, visually separated (a subtle divider or a one-line "default" group label - implementer's call within the app's card/pill idiom). - Badge it: the branch name plus a small `default` tag/badge (the app's `.pill` chip idiom, `web/src/ui.css:85`) so it reads as pinned, not just first. - Dedupe: once the streamed page does include the default branch (small repos), drop the duplicate from the streamed list rather than rendering it twice. - Clicking the pinned row uses the same `pick()` path (`/{full}/tree/{shortRef(name)}`). - Optional server-side nicety (planner's call, not required): a `?pin=<refname>` param on `refs/{branches|tags}` that hoists that ref to the front of the page. The client-side pin alone satisfies the acceptance criteria; do not block on the wire change. ## Part 2: replace the type-select dropdown with two pills above the filter input Today the popover's first row is a `<select class="input">` (branches / tags) sitting beside the filter input (`Repo.jsx:293-311`, `.ref-controls` flex row). Replace it with a two-segment pill toggle, filter input below: ``` [ Branches | Tags ] <- two toggle buttons, segmented-control look [ filter refs… ] <- filter input, full width, stays below the pills [ ref rows … ] ``` - Idiom: reuse the repo's button-pill pattern from #447/#465 - two `btn px-2 py-1 text-sm` buttons with `classList={{ primary: getKind() === "branches" }}` / `… === "tags" }` (same `primary` active-state treatment as WatchToggle/StarToggle), joined as a segmented pair (shared border radius, divider between). Opaque popover rules per #405 apply (the popover already carries `background: var(--panel)` in `.ref-drop` - do not regress it). - Accessibility: each pill is a real `<button type="button">` with `aria-pressed`; the group gets `role="group"` + `aria-label="Ref type"`. Keyboard order becomes pill 1 -> pill 2 -> filter input -> list (native Tab walk); the existing Esc handler (`Repo.jsx:265-271`, #255 outside-click close) is untouched. - Clicking a pill sets `setKind(...)`, clears `setRefs([])` and re-runs `stream.run()` - exactly the current `<select>` onChange behavior. **The filter query persists across the switch** (current contract: `getQuery()` is untouched on kind change) - keep that; the user's typed filter follows them between Branches and Tags. - Layout: `.ref-controls` becomes a column context - pills row on top, input below (flex-col, or a new wrapper). Keep `gap-2` spacing consistent with the header gap conventions (#463). ## Acceptance criteria - [ ] The default branch appears as a selectable, pinned, `default`-badged first row of the branch list in every repo, including repos where HEAD targets a non-`main` name and repos with 50+ alphabetically-earlier branches (walhub itself is the live test case: 115 heads, `main` at ~115) - [ ] Pinned row dedupes against the streamed list when the page happens to contain the default branch (no double render) - [ ] Pinned row click navigates to `/{full}/tree/{default-branch}` via the existing `pick()` path - [ ] The branches/tags `<select>` is gone; two toggle pills (Branches | Tags) sit above the filter input, active state via the `btn … primary` idiom - [ ] Pills swap the list source; the filter input persists its query across the switch; filtering still works per type - [ ] Keyboard navigation preserved: Tab reaches pills -> input -> rows; Esc dismisses and refocuses the trigger; outside-click closes (#255 conventions) - [ ] Popover stays opaque in light + dark (#405; `.ref-drop` keeps its `var(--panel)` background) - [ ] Mobile 390px: the two pills + input fit the popover width (pills row may sit side by side at 320px popover width; the `#278` `max-width: calc(100vw - 1rem)` bound on `.ref-drop` in `web/src/ui.css:204` is preserved) - [ ] Headless tests updated: `web/test/unit/ref-picker-pill.test.js` currently pins `<option value="branches">branches</option>` / `<option value="tags">tags</option>` - replace with pill-toggle assertions; add a default-branch-present assertion (pinned row derived from the summary head, dedupe rule) as a headless pure-function test in `web/src/lib/` (the `ref-pill.js` convention) if the pin derivation is extracted ## References - RefPicker: `web/src/pages/Repo.jsx:224-330` (select block 293-311, stream 240-243, pick 255-260) - Server list: `internal/api/bind_wal.go:360` (`RefList`, name-sorted + `n` cap), `internal/api/refs.go:125` (`refsList`, SSE dialect), `streamRefs` `done {more}` frame the client ignores - Prior fixes: #255 (outside-click close), #278 (popover viewport bound), #405 (opaque popovers), #447/#465 (btn pill idiom + `primary` active state), #252 (pill head context-first)
crueber added this to the v1 milestone 2026-09-13 19:22:02 +00:00
Author
Owner

PR open: #488 (branch fix/issue-482) — client-only pin + pills, no wire change. Tests: node --test 1047 total / 1045 pass / 2 pre-existing live-server smoke failures (identical on main); vite+esbuild green.

PR open: https://git.packden.us/crueber/walhub/pulls/488 (branch fix/issue-482) — client-only pin + pills, no wire change. Tests: node --test 1047 total / 1045 pass / 2 pre-existing live-server smoke failures (identical on main); vite+esbuild green.
Author
Owner

Reviewed PR #488 (fix/issue-482) in scratch worktree /tmp/pr488 against the 9 acceptance criteria. One real defect found and fixed (pushed 352b816 to origin/fix/issue-482); rest verified.

DEFECT (fixed, pushed): the pin derived from the wrong head. RefPicker computed pinnedDefault(head(), kind), but head() is the context-first pill head (pillHead(getViewed(), s().head), #252) — Tree.jsx:197 / Blob.jsx:194 publish the VIEWED ref, so viewing any fix/* branch pinned that branch badged as 'default' while the real default stayed unreachable past the 50-ref window (the exact walhub-itself scenario in criterion 1). Fix (web/src/pages/Repo.jsx): new summaryHead prop carrying s().head straight through (call site :661), pin reads pinnedDefault(summaryHead(), getKind()); pill label keeps head() unchanged. Code now matches its own #482 comments + the issue intent ('derived from the summary head'). Test pins the wiring both ways (pin reads summaryHead(), never head(); call site passes raw s().head). Docs entry updated (prop split + measured counts).

Criteria: (1) pin now reflects HeadTarget in all viewed states (branch incl. non-main defaults, tag/sha-addressed views, empty repo pins nothing); (2) dedupeRefs by full refname, sound, null-safe; (3) pinned row onClick pick(p()), and pick() keys off r.name (refs/tags/ prefix test), so heads/ always take the shortRef tree path — criterion met; (4) / gone from picker scope, two btn+primary buttons, role=group + aria-label Ref type, aria-pressed, switchKind = setKind+clear+re-stream with query untouched; (5) Esc+refocus and outside-click handlers byte-untouched; DOM order gives pill1-pill2-input-rows Tab walk; (6) no CSS touched: repo.css .ref-drop keeps var(--panel) background, ui.css:204 max-width bound intact, theme-independent; (7) 390px by arithmetic: w-80=320px popover under the 374px cap, two short pills + full-width input fit — no browser driven (per review instructions; noted explicitly); (8) option asserts replaced with stronger select-gone + pill/switch/pin/opacity pins, nothing dropped; (9) zero .go files, no manifest changes (no new deps, law 1), 12_web_ui.md entry appended in same change (law 12).

Verify: node --test web/test/unit/*.test.js = 1047 total / 1045 pass / 2 fail; the 2 are the pre-existing live-server smoke subtests (identical pass-1/fail-2 on pristine main — zero PR-caused). Targeted ref-pin-482 + ref-picker-pill: 18/18 pass. vite build + esbuild bundle green. Main worktree untouched (read-only fetch + test run only).

RECOMMENDATION: ready to merge (no browser proof — shared-daemon loopback guard; headless + builds green).

Reviewed PR #488 (fix/issue-482) in scratch worktree /tmp/pr488 against the 9 acceptance criteria. One real defect found and fixed (pushed 352b816 to origin/fix/issue-482); rest verified. DEFECT (fixed, pushed): the pin derived from the wrong head. RefPicker computed pinnedDefault(head(), kind), but head() is the context-first pill head (pillHead(getViewed(), s().head), #252) — Tree.jsx:197 / Blob.jsx:194 publish the VIEWED ref, so viewing any fix/* branch pinned that branch badged as 'default' while the real default stayed unreachable past the 50-ref window (the exact walhub-itself scenario in criterion 1). Fix (web/src/pages/Repo.jsx): new summaryHead prop carrying s().head straight through (call site :661), pin reads pinnedDefault(summaryHead(), getKind()); pill label keeps head() unchanged. Code now matches its own #482 comments + the issue intent ('derived from the summary head'). Test pins the wiring both ways (pin reads summaryHead(), never head(); call site passes raw s().head). Docs entry updated (prop split + measured counts). Criteria: (1) pin now reflects HeadTarget in all viewed states (branch incl. non-main defaults, tag/sha-addressed views, empty repo pins nothing); (2) dedupeRefs by full refname, sound, null-safe; (3) pinned row onClick pick(p()), and pick() keys off r.name (refs/tags/ prefix test), so heads/ always take the shortRef tree path — criterion met; (4) <select>/<option> gone from picker scope, two btn+primary buttons, role=group + aria-label Ref type, aria-pressed, switchKind = setKind+clear+re-stream with query untouched; (5) Esc+refocus and outside-click handlers byte-untouched; DOM order gives pill1-pill2-input-rows Tab walk; (6) no CSS touched: repo.css .ref-drop keeps var(--panel) background, ui.css:204 max-width bound intact, theme-independent; (7) 390px by arithmetic: w-80=320px popover under the 374px cap, two short pills + full-width input fit — no browser driven (per review instructions; noted explicitly); (8) option asserts replaced with stronger select-gone + pill/switch/pin/opacity pins, nothing dropped; (9) zero .go files, no manifest changes (no new deps, law 1), 12_web_ui.md entry appended in same change (law 12). Verify: node --test web/test/unit/*.test.js = 1047 total / 1045 pass / 2 fail; the 2 are the pre-existing live-server smoke subtests (identical pass-1/fail-2 on pristine main — zero PR-caused). Targeted ref-pin-482 + ref-picker-pill: 18/18 pass. vite build + esbuild bundle green. Main worktree untouched (read-only fetch + test run only). RECOMMENDATION: ready to merge (no browser proof — shared-daemon loopback guard; headless + builds green).
Author
Owner

Fixed by PR #488 (review clean + one real defect fixed by reviewer: pin now reads summary head, not viewed ref; all 9 criteria pass), merged. Closing.

Fixed by PR #488 (review clean + one real defect fixed by reviewer: pin now reads summary head, not viewed ref; all 9 criteria pass), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#482
No description provided.