Compass board: Issues/PRs tabs, cross-linking, PR-card badges
Status: Active
Tracker: SEA-1633.
Ledger: this record’s PR appends DL-097 to
docs/designs/product/DECISIONS.md in the same diff (see §Ledger delta) and
supersedes no existing row (§Ledger delta states the call).
Problem
Section titled “Problem”The Bridge board is issues-only: there is no surface that answers “what PRs are
in flight and what state are they in” without clicking through cards one at a
time, and the issue card’s PR status is a per-check pip strip
(IssueCard.tsx:48-57, one check-pip per check) that scales badly past a few
checks and buries the two facts that matter at board distance — is CI green,
and what did review say. Issues and their PRs also don’t link: the card shows
#{number} as inert text, and no PR surface points back at its issue.
Approach
Section titled “Approach”A view remodel of the Bridge, three moves, no model change — the #1018
canonical types already carry everything
(stub-data.ts:236-238 — Issue.prs: “Every PR opened for this issue,
discovery order (newest last)”; :126-133 — ChecksSummary.state: "pending" | "success" | "failure" (“The roll-up”); :151-162 — Review with
submission-ordered reviews so “a reviewer’s CURRENT verdict is its last
entry”).
1. Issues / PRs tabs inside the Bridge
Section titled “1. Issues / PRs tabs inside the Bridge”The split is a peer tab strip inside the board view — a new
BoardTab = "issues" | "prs" signal local to Bridge.tsx, exactly the shape
of the existing BoardMode toggle (Bridge.tsx:14-16 “How the board groups
rows”, :23 createSignal<BoardMode>("swimlane")). Not a new top-level
View (the View union store.ts:67-73 routes whole surfaces —
"bridge" | "backlog" | "done" | … — and the PRs tab is still the Bridge),
and not a third BoardMode (mode is how issues group; the tab is which
artifact kind shows — orthogonal axes, and conflating them would make
“status-grouped PRs” a reachable nonsense state).
- Issues tab = today’s board unchanged:
BOARD_LANEScolumns, swimlane/statusBoardModetoggle,boardAgents/cellItems/laneTotalpartition (board.ts:45-71). Its swimlane row order is Record C’s frozentreeOrder(agents)contract (see §Merge order) — “the board takestreeOrder’s sequence and then keeps onlyboardAgentsrows” (C record §T5). This record cites that contract; it does not redesign it. - PRs tab = a flat row list, one row per open PR (an issue with two
open PRs contributes two rows), grouped by assignee agent with the groups in
the same
treeOrdersequence and an “Unassigned” group last. One-row-per-PR is grounded in the model:Issue.prsis “Every PR opened for this issue” (stub-data.ts:236-238) —primaryPr(board-render.ts:19-27) is a card-level compression for surfaces that can show only one chip; the PRs tab is the surface where the compression must NOT apply, or a second open PR becomes invisible everywhere. - Open PRs only (
forgeState === "open", drafts included): the PRs tab is an attention surface, and a PR needing attention is by definition open. This leaves a gap by design: a non-primary closed/merged PR — one thatprimaryPrdoes not pick (a closed-unmerged PR alongside an open one, or a non-primary merged/closed PR on a done issue) — surfaces nowhere in the UI today, because the card chip, the Done row, and the right-sidebar PR pane each render onlyprimaryPr(board-render.ts:19-27,DoneView.tsx:33-66,RightSidebar.tsx:582-588). A sole closed-unmerged PR is the exception — it isprimaryPrvia the?? prs[prs.length-1]fallback, so it still shows on its card chip and Done row, just not on this open-only tab. Fully closing the gap needs an all-PRs surface, out of scope here (OQ-1’s alternative). Issues in any lifecycle state contribute (the predicate is on the PR, not the issue); pre-active issues rarely have PRs (prsis “empty before the first”). - PR-count on the tab = the number of rows the tab shows, i.e. open PRs across issues (not issues-with-PRs), and — when C’s subtree scope is active — only the rows that scope keeps: the count’s job is “how much is in that tab”, and a row is a PR. Counting issues-with-PRs, or counting unscoped while the rows are scoped, would each disagree with the visible row count.
- The
Swimlanes | Statusseg control renders only on the Issues tab — the PRs tab has one fixed layout, so showing a dead toggle would imply a choice that doesn’t exist.
A PR row renders: prBadge state (board-render.ts:50-54), the forge
coordinate {repo}#{number} (host-qualified via the isMultiForge trigger,
same rule as issueKey), the PR title, the CI badge + review badge (below),
the thread tally resolved/total (counting ReviewThread.resolved,
stub-data.ts:144-149), and the owning issue’s issueKey as the cross-link
chip.
2. Cross-linking issues ↔ PRs
Section titled “2. Cross-linking issues ↔ PRs”Selection stays issue-keyed — no new PR-selection store state. Both tabs
share store.selectedIssueId() (store.ts:243-244) as the highlight key, and
both directions of the link ride the existing nav verbs
(selectIssue, store.ts:870-873, “Select an issue (card / swimlane cell)
and sync the roster”; openAgent, :818):
- Issue card → PR: the card’s
card-prchip (today inert text#{p().number},IssueCard.tsx:60) becomes interactive: clicking it selects the issue and flips the Bridge to the PRs tab, where that issue’s row(s) carry the selected style. The tab signal is Bridge-local, so Bridge passes IssueCard an optionalonOpenPr?: () => voidcallback prop; a card rendered anywhere without the prop keeps the chip inert. Because the whole card is a<button>(IssueCard.tsx:33-40), the chip must not nest a second button: it becomes a<span role="link" tabindex="0">with click/keydown handlers thatstopPropagation(). That downgrades but does not eliminate the defect — atabindex="0"span is still an interactive descendant, which the<button>content model forbids, so AT exposure stays imperfect; the clean fix (card asdiv[role="button"]) is deliberately out of this record’s scope (it restructures the pre-existing card). - PR row → issue: the row’s
issueKeychip callsselectIssue(row.issue.id)and flips back to the Issues tab; the row’s agent group header reuses the swim-gutter behavior (store.openAgent(agent.account.id),Bridge.tsx:104-107). The row body itself (like a card) selects the issue without leaving the tab, so the right-sidebar PR pane — which already renders from the selected issue — follows.
3. Card status collapses to CI + review badges
Section titled “3. Card status collapses to CI + review badges”The per-check pip strip on the issue card (IssueCard.tsx:48-57 — <For each={checks().checks}> → check-pip per check) is replaced by two compact
badges, derived in board-render.ts alongside checkPip/prBadge so card,
Done view, and PR rows cannot drift:
- CI badge = the roll-up
ChecksSummary.stateread directly — it is already the 3-valued roll-up (“pending” | “success” | “failure”,stub-data.ts:126-133); no new helper is needed, and inventing one would duplicate the model’s own contract. Nochecks→ no CI badge (sameShow when={p().checks}guard as today). - Review badge = a new pure helper
reviewBadge(pr)rolling latest-per-author verdicts to one value. Precedence, stated explicitly: take each author’s LAST review in submission order (the model’s own rule: “a reviewer’s CURRENT verdict is its last entry”,stub-data.ts:151-154), bots included (the existing bot chips already take latest-per-author); thenchanges_requesteddominates (one blocking reviewer blocks), elseapprovedif any (someone signed off and nobody objects), elsecommented, and emptyreviews→undefined(no badge). The helper returns the display vocabulary"changes" | "approved" | "commented"— thechanges_requested→changesmap moves INTO the total function (one owner) rather than repeating per chip site. - The Done view’s identical pip strip (
DoneView.tsx:37-46) collapses to the same two badges pending OQ-2. The right-sidebarCheckRunspane (RightSidebar.tsx:188-205) is the per-check DETAIL surface and keeps its pips — the collapse is a board-distance move, not a pip ban. This card/Done collapse supersedes the #1018 issue-model record’s explicit pip-per-check prescription for those two surfaces (compass-issue-model/design.md:712-728, “substituting [the roll-up] would collapse a 3-check PR’s three pips into one”), authorized by Matt’s 2026-08-01 dispatch;CheckRunskeeps the per-check pips that record protects.
Merge order
Section titled “Merge order”This record merges AFTER Record C (agent trees, PR #1058, DL-095) and Record A
(sidebar pins, PR #1059, DL-096). The Issues tab’s swimlane ordering and the
PRs tab’s group ordering both assume C’s board.ts helpers exist:
treeOrder(agents: readonly Agent[]): Agent[] (“depth-first order over the
derived tree … the swimlane ordering”, C record §T5) and
subtreeAgentIds(agents, rootAgentId): ReadonlySet<string> (“the subtree
membership set, the board filter predicate … includes rootAgentId”) — when
C’s subtree scope filter is active, the PRs tab honors the same predicate
(rows whose issue.assignee is in the set, plus the unassigned group only in
the unscoped view). C is now merged (PR #1058), so DL-095 is on main and the
DL-097 row’s reference resolves; A (DL-096) merges before this record.
Global Constraints
Section titled “Global Constraints”- Sequencing: merges AFTER Record C (#1058, DL-095) and Record A (#1059,
DL-096); the swimlane/group ordering consumes C’s
treeOrderandsubtreeAgentIds(board.ts) as frozen contracts — do not reimplement or fork them. - NO model change: no proto, server, or
stub-data.tstype edits. The #1018 canonicalIssue/PullRequest/ChecksSummary/Reviewtypes (DL-067..070 + DL-091, frozen) carry every field this view reads. A task that seems to need a model field is a STOP-and-escalate, not a local addition. - Derivations live in
board-render.ts; partitions inboard.ts— pure, total functions over injected lists (no store/fixture imports), matchingcheckPip/prBadge/primaryPrandboardAgents/cellItems. Every new helper ships withboard-render.test.ts/board.test.tscases in the existing table style. - SolidJS + existing board patterns: local
createSignalfor view-local UI state (the tab, likeBoardMode), store verbs (selectIssue,openAgent) for navigation; no new store state unless a task names it. - No nested
<button>inside the card button — interactive chips insideIssueCarduserole="link"+ keyboard handling +stopPropagation()(a content-model compromise inherited from the card-as-button, §2). - Naming/copy: tab labels exactly
IssuesandPRs; the PRs tab count is the visible row count.
T1 — roll-up helpers (board-render.ts)
Section titled “T1 — roll-up helpers (board-render.ts)”The pure derivations both tabs and the card share. No UI change yet.
- Interfaces:
reviewBadge(pr: PullRequest): "changes" | "approved" | "commented" | undefined— latest-per-author overpr.reviews(submission order, bots included), then precedencechanges_requested>approved>commented;[]→undefined. Mapschanges_requested→changesinside the helper (the one owner of that copy rule).ciBadge(pr: PullRequest): ChecksSummary["state"] | undefined— a thin total accessorpr.checks?.state, exported so every badge site reads the roll-up through one named seam (and the “pips iteratechecks, NOT the roll-up” doc comment inverts here on purpose).openPrs(issue: Issue): PullRequest[]—issue.prs.filter(p => p.forgeState === "open"),prsorder preserved; the PRs-tab row source.
- Tests:
board-render.test.ts— reuse itspr()/issue()fixture builders (board-render.test.ts:23-39,43-63); cases: same-author supersession in BOTH directions — author’s laterapprovedbeats their earlierchanges_requested, AND a latercommentedsupersedes their earlierapproved(the badge drops, pinning the model rule in the unflattering direction) — cross-authorchanges_requesteddominance, approved vs commented, empty → undefined;openPrskeeps order and drops merged/closed.
T2 — PR partition (board.ts)
Section titled “T2 — PR partition (board.ts)”The pure PRs-tab grouping, beside the issue partition.
- Interfaces:
type PrRow = { issue: Issue; pr: PullRequest }(exported fromboard.ts).prRows(all: readonly Issue[]): PrRow[]— flat-mapsopenPrsover every issue (any lifecycle state), issue order thenprsorder preserved.prRowGroups(agents: readonly Agent[], all: readonly Issue[]): { agent: Agent | null; rows: PrRow[] }[]— groupsprRowsbyissue.assignee; group sequence =treeOrder(agents)(C’s contract) filtered to agents with ≥1 row, then oneagent: null(“Unassigned”) group last iff non-empty.prCount(all: readonly Issue[], scope?: ReadonlySet<string>): number— open-PR row count; whenscope(C’ssubtreeAgentIds) is passed, counts only rows whoseissue.assigneeis in it, so the badge equals the visible row count.prRows(all).lengthwhen unscoped.
- Tests:
board.test.ts— two-open-PR issue yields two rows; merged/closed excluded; group order followstreeOrder(parent/child fixture); unassigned group last and absent when empty;prCountequals total rows unscoped, and with ascopeset counts only in-scope-assignee rows (unassigned excluded).
T3 — Bridge tabs + PRs tab render (Bridge.tsx)
Section titled “T3 — Bridge tabs + PRs tab render (Bridge.tsx)”- Interfaces:
type BoardTab = "issues" | "prs";createSignal<BoardTab>("issues")beside the existingBoardModesignal (Bridge.tsx:23).- Toolbar: a tab seg —
Issues, andPRs · {prCount(store.issues(), scope())}wherescope()is C’s activesubtreeAgentIds(orundefinedwhen unscoped) — added tobridge-toolbar(Bridge.tsx:45-66); theSwimlanes|Statusseg wrapped in<Show when={tab() === "issues"}>. - PRs tab body:
<For each={prRowGroups(STUB_AGENTS, store.issues())}>→ group header (agent handle,openAgenton click, reusing swim-gutter affordances) +<For each={rows}>→PrRowrows. Row body click →store.selectIssue(row.issue.id);classList={{ selected: row.issue.id === store.selectedIssueId() }}. - A
PrRowrow component (inBridge.tsxor a siblingPrRowItem.tsxif it crosses ~60 lines):prBadge(pr)state chip,{pr.repo}#{pr.number}(host-qualified whenisMultiForge(store.issues())),pr.title, CI badge (ciBadge), review badge (reviewBadge), thread tally{resolved}/{total}, and the owning issue’sissueKey(row.issue, multiForge)chip →selectIssue+ flip to Issues tab. - When C’s subtree scope is active, the PRs tab filters rows by
subtreeAgentIdsmembership ofissue.assignee(unassigned group only unscoped) — same predicate as the Issues tab — and the samescope()set feedsprCount(above), so the tab badge tracks the filtered rows.
- Tests: a new
Bridge.test.tsx(none exists today —App.test.tsxcovers routing): tab flip renders rows; PR count in the tab label matchesprCount; mode seg hidden on PRs tab; issueKey chip flips tab + selects. - CSS:
app.css— tab seg reuses.seg; new.pr-row/.pr-groupstyles; badge styles in T4.
T4 — card + Done badges (IssueCard.tsx, DoneView.tsx, app.css)
Section titled “T4 — card + Done badges (IssueCard.tsx, DoneView.tsx, app.css)”- Interfaces:
IssueCard.tsx:48-57: thecheck-pips<For>strip is REPLACED by<span class="ci-badge" data-status={ciBadge(p())}>(guarded by the existingShow when={p().checks}) +<Show when={reviewBadge(p())}>→<span class="review-badge" data-verdict={…}>.checkPipimport drops from the card.- New prop
onOpenPr?: () => voidonIssueCard; thecard-prchip becomesrole="link"+tabindex="0", click/Enter →stopPropagation()+onOpenPr; without the prop the chip stays inert. Bridge passes() => { store.selectIssue(issue.id); setTab("prs"); }. DoneView.tsx:37-46: same pip-strip → badges replacement (pending OQ-2’s default: yes, collapse it too).app.css:763-782:.check-pips/.check-piprules stay (RightSidebarCheckRunsstill uses them,RightSidebar.tsx:188-205); add.ci-badge[data-status]and.review-badge[data-verdict]color rules on the existing--add/--del/--warnvars.
- Tests:
board-render.test.tsalready covers the helpers (T1); card-level DOM assertions go inBridge.test.tsx(selected-card badge presence) — there is no existingIssueCardtest file to extend.
- T1 —
reviewBadge/ciBadge/openPrsinboard-render.ts+board-render.test.tscases - T2 —
PrRow/prRows/prRowGroups/prCountinboard.ts+board.test.tscases - T3 —
BoardTabseg inBridge.tsx, PRs tab render, count badge, cross-link chips,Bridge.test.tsx - T4 — card + Done pip strips → CI/review badges,
onOpenPrchip,app.cssbadge styles
Ledger delta
Section titled “Ledger delta”This PR appends one row under ## UI shell in
docs/designs/product/DECISIONS.md (after DL-091, where the board/issue-model
decisions sit). It supersedes no ledger ROW: DL-031 (board-primary shell),
DL-067/069 (issue as board unit, canonical types), and DL-091 (archive
lifecycle) all stand — this row composes a view layout onto them; no Active row
asserts the board’s tab structure, the card’s pip strip, or PR-surface shape.
It does supersede, at PROSE granularity, the #1018 issue-model record’s
pip-per-check prescription for the card and Done view (§3; that record’s
CheckRuns pips stand) — no ledger row change needed, per Matt’s 2026-08-01
dispatch. C is merged (#1058), so this row’s DL-095 reference resolves on
main; A (DL-096) merges before it (§Merge order).
| DL-097 | The Bridge board splits into peer Issues/PRs tabs inside the board view (a Bridge-local tab signal, not a new
Viewor a thirdBoardMode): the Issues tab is today’s board ordered by Record C’streeOrder; the PRs tab is a flat one-row-per-OPEN-PR list (theprimaryPrcompression deliberately does not apply) grouped by assignee intreeOrderwith the tab badge = the open-PR row count; issues ↔ PRs cross-link through the existing issue-keyed selection (selectIssue, no new PR-selection state); and the card’s per-check pip strip collapses to a CI badge (theChecksSummary.stateroll-up) + a review badge (latest-per-author verdict,changes_requested>approved>commented) as pureboard-render.tshelpers — a VIEW remodel consuming the frozen #1018 model, no proto/server/model change | Active (Matt, 2026-08-01) | board view §Approach |
Open Questions
Section titled “Open Questions”None are load-bearing; the record designs against the stated defaults.
- OQ-1 — PRs tab scope: open-only vs all PRs. Designed default: open PRs only (drafts included) — an attention surface should show actionable items, and the non-primary-closed-PR gap (§1) is a known limitation that no current surface covers. Alternative: show all with the badge counting open only; closes the gap but adds noise for little value. Recommendation: open-only.
- OQ-2 — Done view pip strip. The dispatch names the card; the Done view
has the identical strip (
DoneView.tsx:37-46). Designed default: collapse it to the same two badges (one derivation, no drift). Alternative: leave Done’s pips as a historical-detail surface. Recommendation: collapse both; the per-check detail home is the RightSidebarCheckRunspane. - OQ-3 — cross-link tab flip. Designed default: the card’s PR chip flips the Bridge to the PRs tab (a true cross-link between the tabs). Alternative: the chip only selects the issue and lets the right-sidebar PR pane show the detail, no tab flip. Recommendation: flip — the link should land where the PR rows live; the sidebar already follows selection either way.