Repo tab bar: open-count badges next to Issues and Pulls #319

Closed
opened 2026-09-11 11:27:52 +00:00 by crueber · 3 comments
Owner

What's requested

Add a count badge next to the Issues and Pulls tabs in the repo tab bar showing the number of open issues / open pull requests. Badge only renders when the count is > 0.

Current state (code evidence)

  • The repo tab bar is the TABS array + <For> render in web/src/pages/Repo.jsx:160-170 / :603-612 — plain <A> links with label text, no per-tab data slots.
  • No aggregate open-count endpoint exists. GET …/issues?state=open returns {issues, more} (a windowed page, capped at 100 — not a count), and pulls are the same (ListPRs → {pulls, more}). OpenIssues int exists only on the milestone shape (internal/issues/model.go:171). There is no cheap "open count for the repo" anywhere — the badge needs one, or it would cost a full list fetch per tab render.

Proposed design

  1. Server: repo-level open counts. Cheapest correct source: both collab packages already maintain CAS'd indexes — the issues index separates Open/ClosedRecent card arrays (internal/issues/model.go:141-145), so open-issue count is len(ix.Open) (O(1) read of an existing object; same index the list endpoint serves from). Pulls should mirror this (verify the pulls index shape; if pulls lack a state-partitioned index, either add one or count via the existing store listing with the same index-first discipline — planner's call).
    • API: extend the repo summary (GET …/api, summaryBody in internal/api/summary.go:12) with open_issues / open_pulls ints, OR add count endpoints (GET …/api/issues/count, …/api/pulls/count). Recommended: ride the summary — the tab bar already renders from the shell which holds the shared summary signal (RepoCtx), so the badge needs zero new requests; the counts are there on every repo page already. Summary is SWR/ETag'd on head sha — the cache trap from #235/#247 applies: an issue/PR mutation with no ref move would serve stale counts — the ETag must incorporate the collab-state version (or the counts ride a no-cache class per the #280 decision once landed). Call this out in the implementation.
    • Alternative if summary coupling is unwanted: two count endpoints read once per repo visit through useData (REPO_TTL). Acceptable, but two extra requests per repo view for one badge.
  2. Client: badge in the tab render. Extend the TABS entry (or the <For> body) so issues/pulls tabs render <Show when={count > 0}><span class="…rounded-full bg-emerald-600 text-white text-xs…">{count}</span></Show> after the label. Match the app's existing count-pill styling (the notification tray badge / milestone counts use chip-family styling — reuse rather than invent). Hide at 0 (GitHub semantics: no zero badges).
  3. Live updates. The collab stream already broadcasts issue/pull frames; the shell (or the badge) should adjust counts on frames rather than waiting for the next summary revalidation — the adjustSummary-style pattern exists in lib/reactions.js for in-place counters. Scope: at minimum invalidate the summary entry on frames; optimistic count adjust is a nice-to-have.
  4. SSE/pull events on tab bars elsewhere: the badge counts derive from the shared summary — the repo shell's existing summary refresh cadence covers background updates.

Acceptance criteria

  • Issues and Pulls tabs show a numeric badge = open count, only when > 0; correct counts verified against the filtered lists.
  • Counts come from an O(1) index read server-side (no per-render list scan; no windowed-list-based counting).
  • The summary-ETag/cache trap is handled: a close/reopen of an issue or PR is reflected on the next summary revalidation at the latest, and the chosen cache-class change is documented (ties into the #280 no-cache decision if landed).
  • issue/pull stream frames update the badge without a full reload (invalidate-at-minimum).
  • Badge styling matches the app's pill/chip language in light and dark themes; does not overflow the tab bar at 390px (mobile sweep context — tab bar already scrolls, #274).
  • Route twins + SDK updated for whatever wire shape lands; headless test for badge render logic (count 0 → hidden, > 0 → shown).
## What's requested Add a **count badge next to the Issues and Pulls tabs** in the repo tab bar showing the number of open issues / open pull requests. Badge only renders when the count is > 0. ## Current state (code evidence) - The repo tab bar is the `TABS` array + `<For>` render in `web/src/pages/Repo.jsx:160-170` / `:603-612` — plain `<A>` links with label text, no per-tab data slots. - **No aggregate open-count endpoint exists.** `GET …/issues?state=open` returns `{issues, more}` (a windowed page, capped at 100 — not a count), and pulls are the same (`ListPRs` → `{pulls, more}`). `OpenIssues int` exists only on the *milestone* shape (`internal/issues/model.go:171`). There is no cheap "open count for the repo" anywhere — the badge needs one, or it would cost a full list fetch per tab render. ## Proposed design 1. **Server: repo-level open counts.** Cheapest correct source: both collab packages already maintain CAS'd indexes — the issues index separates `Open`/`ClosedRecent` card arrays (`internal/issues/model.go:141-145`), so open-issue count is `len(ix.Open)` (O(1) read of an existing object; same index the list endpoint serves from). Pulls should mirror this (verify the pulls index shape; if pulls lack a state-partitioned index, either add one or count via the existing store listing with the same index-first discipline — planner's call). - **API:** extend the repo summary (`GET …/api`, `summaryBody` in `internal/api/summary.go:12`) with `open_issues` / `open_pulls` ints, OR add count endpoints (`GET …/api/issues/count`, `…/api/pulls/count`). **Recommended: ride the summary** — the tab bar already renders from the shell which holds the shared summary signal (`RepoCtx`), so the badge needs zero new requests; the counts are there on every repo page already. Summary is SWR/ETag'd on head sha — the cache trap from #235/#247 applies: **an issue/PR mutation with no ref move would serve stale counts** — the ETag must incorporate the collab-state version (or the counts ride a `no-cache` class per the #280 decision once landed). Call this out in the implementation. - Alternative if summary coupling is unwanted: two count endpoints read once per repo visit through `useData` (REPO_TTL). Acceptable, but two extra requests per repo view for one badge. 2. **Client: badge in the tab render.** Extend the `TABS` entry (or the `<For>` body) so `issues`/`pulls` tabs render `<Show when={count > 0}><span class="…rounded-full bg-emerald-600 text-white text-xs…">{count}</span></Show>` after the label. Match the app's existing count-pill styling (the notification tray badge / milestone counts use `chip`-family styling — reuse rather than invent). Hide at 0 (GitHub semantics: no zero badges). 3. **Live updates.** The collab stream already broadcasts `issue`/`pull` frames; the shell (or the badge) should adjust counts on frames rather than waiting for the next summary revalidation — the `adjustSummary`-style pattern exists in `lib/reactions.js` for in-place counters. Scope: at minimum invalidate the summary entry on frames; optimistic count adjust is a nice-to-have. 4. **SSE/pull events on tab bars elsewhere:** the badge counts derive from the shared summary — the repo shell's existing summary refresh cadence covers background updates. ## Acceptance criteria - [ ] Issues and Pulls tabs show a numeric badge = open count, only when > 0; correct counts verified against the filtered lists. - [ ] Counts come from an O(1) index read server-side (no per-render list scan; no windowed-list-based counting). - [ ] The summary-ETag/cache trap is handled: a close/reopen of an issue or PR is reflected on the next summary revalidation at the latest, and the chosen cache-class change is documented (ties into the #280 no-cache decision if landed). - [ ] `issue`/`pull` stream frames update the badge without a full reload (invalidate-at-minimum). - [ ] Badge styling matches the app's pill/chip language in light and dark themes; does not overflow the tab bar at 390px (mobile sweep context — tab bar already scrolls, #274). - [ ] Route twins + SDK updated for whatever wire shape lands; headless test for badge render logic (count 0 → hidden, > 0 → shown).
crueber added this to the v1 milestone 2026-09-11 11:27:52 +00:00
Author
Owner

Fixed by #322 (branch fix/issue-319): open-count badges ride the repo summary (open_issues/open_pulls + ~c ETag suffix), tab render hides at 0, stream + mutation-site invalidation per the #318 pattern. SWR class retained per the #235/#240 precedent, coordinated with #280. Tests + docs in the PR — review welcome, not merging per instructions.

Fixed by #322 (branch fix/issue-319): open-count badges ride the repo summary (open_issues/open_pulls + ~c ETag suffix), tab render hides at 0, stream + mutation-site invalidation per the #318 pattern. SWR class retained per the #235/#240 precedent, coordinated with #280. Tests + docs in the PR — review welcome, not merging per instructions.
Author
Owner

REVIEW PR #322 (fix/issue-319, commit f71e7fe) — verified in scratch worktree /tmp/pr322 (removed afterward). No browser (tests + reasoning, per instructions). Main worktree untouched (still clean apart from pre-existing untracked .opencode/).

FINDINGS — all acceptance criteria hold, no fixes needed:

  1. Counts correct (open-only, misfiled excluded, PRs from same index) — PASS. OpenCounts (internal/issues/service.go:895) reads the shared P4 index.json (issues IndexKey and pulls IndexKey are byte-identical: repos///issues/index.json) with one exact-key GET, counts ix.Open filtered by State==open AND Kind in {issue,pr}; misfiled closed cards and unknown kinds excluded (covered by TestOpenCountsIgnoresMisfiledCards). Both writers bump ix.Version on every upsert (issues/store.go:242, pulls/service.go:239), so the ETag version covers PR mutations too. CompactIndex evicts closed_recent only — open reads are exact. Counts cross-checked against ListIssues in TestOpenCountsIssues.
  2. Zero new client requests — PASS. Badge reads getSummary() (Repo.jsx:501, the shell's existing repo:{full} useData entry); tabBadge() (lib/tabs.js) is a pure projection. No new endpoint; SDK passthrough is free (repo.get wired in sdk/admin.js:18 returns the whole payload; types.js typedef only). Rejected two-endpoint alternative documented in 07 §9.1.
  3. ETag trap — PASS. ~c suffix only when countsOK (summary.go:144); absent index → byte-identical ETag (law 12, pre-collab repos untouched). Close/reopen with no ref move busts SWR — proven by TestSummaryCollabCountsRevalidate (200 not 304 on version bump, then 304 when current). All-closed still versioned (~c9 case) so last-close busts too.
  4. SWR class coordination with #280 — PASS, not duplicating. #280 landed as #292 (ccNoCache for version-keyed collab GETs); summary stays SWR per the #235/#240 precedent with version-covering ETag, residual ≤60s window closed client-side via stream invalidation. Test pins Cache-Control == ccSWR. Doc states the coordination explicitly in both 07 §9.1 spots.
  5. Fail-open — SANE. Hook (cmd/walhub/collab.go) maps err/!ok → zeros with no suffix; corrupt/store-error OpenCounts surfaces err to the hook, summary still 200s. Display metadata must never fail the repo shell — matches mirror/degraded precedent.
  6. Live updates — PASS (invalidate-at-minimum per criteria). collabKeys gains repo:{full} for issue+pull frames; invalidateIssueLists + Pull.jsx reload reconcile at mutation sites (#318 pattern). Correct scoping: review/issue_event/thread frames rightly excluded (no count effect).
  7. Badge UX — PASS. Show when n()>0 only; tabBadge clamps negatives, maps null/undefined/pre-#319 {} → 0 (headless-tested). .tab-badge reuses tray count-pill language (white on emerald-500, no dark: variant needed), tab bar already overflow-x-auto so no 390px overflow. a11y: span aria-label=' open' inside the link → link name 'Issues 3 open'.
  8. No new deps — PASS (no package.json/lock diff; go.mod untouched — chi/BurntSushi/x-* budget intact).
  9. Docs — ACCURATE. Decisions in 07_api.md (§9.1 + Decisions), 02_issues.md, 08_ui_sdk.md; cost story disclosed (+1 server probe per summary, off law-6 budgeted paths which never call here). Apidocs.jsx route line updated.
  10. Law 8 — PASS. api never imports issues: CollabCounts func type on Env (env.go:542), wired by composition in cmd/walhub.

VERIFICATION (scratch worktree, commit f71e7fe):

  • gofmt -l on touched Go dirs: clean. go vet api/issues/cmd: clean.
  • go test -race internal/api: ok. internal/issues: ok. internal/pulls (shared-index regression): ok.
  • coverage: api 95.5%, issues 96.3% — ≥95% gate holds.
  • node --test web/test/unit/*.test.js: 606 pass, 0 fail.
  • vite build: ok (536KB chunk-size warning only, pre-existing shape). esbuild SDK bundle: ok.
  • Small problems found: NONE. Nothing pushed.

MERGE RECOMMENDATION: ready to merge.

REVIEW PR #322 (fix/issue-319, commit f71e7fe) — verified in scratch worktree /tmp/pr322 (removed afterward). No browser (tests + reasoning, per instructions). Main worktree untouched (still clean apart from pre-existing untracked .opencode/). FINDINGS — all acceptance criteria hold, no fixes needed: 1. Counts correct (open-only, misfiled excluded, PRs from same index) — PASS. OpenCounts (internal/issues/service.go:895) reads the shared P4 index.json (issues IndexKey and pulls IndexKey are byte-identical: repos/<o>/<r>/issues/index.json) with one exact-key GET, counts ix.Open filtered by State==open AND Kind in {issue,pr}; misfiled closed cards and unknown kinds excluded (covered by TestOpenCountsIgnoresMisfiledCards). Both writers bump ix.Version on every upsert (issues/store.go:242, pulls/service.go:239), so the ETag version covers PR mutations too. CompactIndex evicts closed_recent only — open reads are exact. Counts cross-checked against ListIssues in TestOpenCountsIssues. 2. Zero new client requests — PASS. Badge reads getSummary() (Repo.jsx:501, the shell's existing repo:{full} useData entry); tabBadge() (lib/tabs.js) is a pure projection. No new endpoint; SDK passthrough is free (repo.get wired in sdk/admin.js:18 returns the whole payload; types.js typedef only). Rejected two-endpoint alternative documented in 07 §9.1. 3. ETag trap — PASS. ~c<index-version> suffix only when countsOK (summary.go:144); absent index → byte-identical ETag (law 12, pre-collab repos untouched). Close/reopen with no ref move busts SWR — proven by TestSummaryCollabCountsRevalidate (200 not 304 on version bump, then 304 when current). All-closed still versioned (~c9 case) so last-close busts too. 4. SWR class coordination with #280 — PASS, not duplicating. #280 landed as #292 (ccNoCache for version-keyed collab GETs); summary stays SWR per the #235/#240 precedent with version-covering ETag, residual ≤60s window closed client-side via stream invalidation. Test pins Cache-Control == ccSWR. Doc states the coordination explicitly in both 07 §9.1 spots. 5. Fail-open — SANE. Hook (cmd/walhub/collab.go) maps err/!ok → zeros with no suffix; corrupt/store-error OpenCounts surfaces err to the hook, summary still 200s. Display metadata must never fail the repo shell — matches mirror/degraded precedent. 6. Live updates — PASS (invalidate-at-minimum per criteria). collabKeys gains repo:{full} for issue+pull frames; invalidateIssueLists + Pull.jsx reload reconcile at mutation sites (#318 pattern). Correct scoping: review/issue_event/thread frames rightly excluded (no count effect). 7. Badge UX — PASS. Show when n()>0 only; tabBadge clamps negatives, maps null/undefined/pre-#319 {} → 0 (headless-tested). .tab-badge reuses tray count-pill language (white on emerald-500, no dark: variant needed), tab bar already overflow-x-auto so no 390px overflow. a11y: span aria-label='<n> open' inside the link → link name 'Issues 3 open'. 8. No new deps — PASS (no package.json/lock diff; go.mod untouched — chi/BurntSushi/x-* budget intact). 9. Docs — ACCURATE. Decisions in 07_api.md (§9.1 + Decisions), 02_issues.md, 08_ui_sdk.md; cost story disclosed (+1 server probe per summary, off law-6 budgeted paths which never call here). Apidocs.jsx route line updated. 10. Law 8 — PASS. api never imports issues: CollabCounts func type on Env (env.go:542), wired by composition in cmd/walhub. VERIFICATION (scratch worktree, commit f71e7fe): - gofmt -l on touched Go dirs: clean. go vet api/issues/cmd: clean. - go test -race internal/api: ok. internal/issues: ok. internal/pulls (shared-index regression): ok. - coverage: api 95.5%, issues 96.3% — ≥95% gate holds. - node --test web/test/unit/*.test.js: 606 pass, 0 fail. - vite build: ok (536KB chunk-size warning only, pre-existing shape). esbuild SDK bundle: ok. - Small problems found: NONE. Nothing pushed. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #322 (review clean; counts + ETag + zero-new-requests verified; 606/606), merged. Closing.

Fixed by PR #322 (review clean; counts + ETag + zero-new-requests verified; 606/606), 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#319
No description provided.