8000 fix(sidebar): render the PR icon, and let a stale PR badge come back down by derekneely · Pull Request #182 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

fix(sidebar): render the PR icon, and let a stale PR badge come back down - #182

Merged
amirlehmam merged 3 commits into
amirlehmam:masterfrom
derekneely:fix/stale-pr-status-4
Aug 20, 2026
Merged

amirlehmam merged 3 commits into
amirlehmam:masterfrom
derekneely:fix/stale-pr-status-4

Conversation

@derekneely
Copy link
Copy Markdown
Contributor

The PR badge on a workspace row can be raised but never lowered, and its icon
doesn't reach the screen. Three commits, each standing on its own.

1. The icon never renders

gh pr view --json state answers in GitHub's casing — OPEN / MERGED /
CLOSED — and report_pr carries that string through untouched.
SidebarMetadata.prStatus is a plain string while WorkspaceInfo.prStatus is
the lowercase union, so the casing difference has nothing to catch it on the way
into the store.

PrStatusIcon switches on the lowercase union, so every real report falls past
all three arms and the component returns undefined. React 19 renders that as
nothing rather than throwing, so the row degrades quietly to a bare
#450 MERGED with a missing glyph — which reads more like a stuck badge than a
merged one.

The fold onto the union lives next to the icon as a small pure function, so the
render boundary is safe whatever a caller passes, and the switch gains an
explicit null default. Badge text keeps its uppercase appearance via
text-transform, so nothing about the row changes except the icon appearing.

2. A badge belongs to the pane that reported it

PR metadata is workspace-scoped while every PowerShell pane in the workspace
runs its own poller, and they all write the same row. Two things follow.

A clear_pr from one pane could take down a PR another pane had just reported —
two panes, two repos, one workspace. The reporting surface is now recorded
alongside the PR (prSurfaceId) and a clear_pr is honoured only from that
surface. Tracking it shell-side wouldn't be enough on its own: a poller job
can't know another pane has since overwritten the row.

And closing the reporting pane left the badge up — the case the report opens
with. That can't be answered from the shell, since a killed PTY runs no exit
handler, so the badge is dropped where the surface itself goes away: the same
close chokepoints that already reap the PTY. A surface merely moved between
panes keeps its badge; only a real close drops it.

A shell that dies inside a still-open tab reaches none of those transitions, so
that one is answered from the pty:exit handler in useTerminal.ts — already
where a stuck "Running" badge and a leftover progress indicator are healed for
the same reason, and a signal that arrives whether the shell left gracefully or
was killed.

3. The poller can lower a badge, and follows the pane

clear_pr is handled in App.tsx but has no sender, unlike clear_git_branch,
whose clearing half is wired up in both integrations. The tick reports only on
the success path, so a gh pr view that finds nothing sends nothing.

The job's location is the other half. Start-Job hands the child runspace the
location captured at call time and keeps it there. Starting the poller on the
shell's first idle is what keeps several hundred ms of runspace startup off the
first prompt and is worth keeping — but it does mean a pane that cd's into
another repo or worktree keeps being answered for the first one.

The tick now decides through one pure function and the prompt publishes the
pane's live location for the job to read back each tick:

Pane state Sends
In a repo, gh resolves a PR report_pr
In a repo, gh finds nothing, this pane had reported clear_pr
In a repo, gh finds nothing, this pane never reported nothing
Not in a repo, this pane had reported clear_pr
Not in a repo, never reported nothing

Retracting only its own claim is what keeps a pane from speaking for panes it
knows nothing about — one workspace with a pane on a PR branch and a pane in ~
would otherwise take turns reporting and clearing every 45s — and it means the
pipe goes quiet once a badge is down. The renderer honours a clear only from the
surface that owns the row, so a retraction can never reach past its own claim.

Three edges came with it: the "did I claim this" flag now advances only on a
send that actually landed (a clear_pr that failed to go out used to leave the
pane believing it had nothing left to retract); an unreadable cwd hand-off is
treated as no information rather than "stay put", which would have gone on
polling a stale repo; and the hand-off files of panes that were killed rather
than closed are swept by age, since a kill runs no exit handler. A live pane
keeps its own file young from both ends — the prompt rewrites it on every
command and the poller touches it on every tick — so a pane parked at an idle
prompt for days can't be swept out from under itself.

Calls that felt like yours to make, not mine

Should a merged PR display at all? Left exactly as it is. gh pr view
resolves a PR from its branch after it's merged, so a pane sitting on a merged
branch legitimately keeps reporting MERGED — refreshed, not stale. Now that
the icon renders, that may well be the useful behaviour; if you'd rather a
merged PR cleared itself, that's a one-line change and I'm glad to make it.

When a pane walks out of a repo. It retracts its own badge, on the reasoning
that the prompt drops the branch off the row on the very next command and a PR
sitting beside a row with no repo behind it reads as stuck. Happy to make that
row of the table silent instead if you'd read it the other way.

Why the cwd hand-off is a file and not the pipe

Worth stating outright, since the pipe is the obvious candidate. It runs one
direction — shell to wmux — and the consumer here is another shell process.
The prompt already sends this exact value across as report_pwd; routing the
hand-off through the pipe would mean adding currentCwd to the surface listing,
a V2 method and a CLI verb to read it back, and a node spawn on every 45s
tick, so that the shell could ask wmux for something the shell itself had just
told it.

The file lives in <temp>\wmux — the directory wmux-bash-integration.sh
already uses for its own hand-off — is removed on shell exit, and panes that
were killed rather than closed are swept by age on a later shell's first idle,
so the directory can't grow one file per pane forever.

Known edge

If gh is unauthenticated while the pane is in a repo, gh pr view fails the
same way "no PR on this branch" does, so a pane that had reported retracts once.
It re-reports as soon as auth is restored. Separating the two means parsing gh's
stderr text, which doesn't hold across gh versions — the single spurious clear
seemed the better trade, but say the word if you'd rather gate on
gh auth status.

Verification

  • npx vitest run — 1225 passed / 100 files (master @ v1.5.1: 1159 / 95).
  • npm run lint — unchanged; the errors in touched files are pre-existing on
    master (confirmed by stashing).
  • Real PowerShell host, both 7 and Windows PowerShell 5.1, including the
    decision functions run inside an actual Start-Job -InitializationScript —
    a job runspace inherits none of the session's functions, and a silent failure
    there would look exactly like the bug.
  • A parse gate on the whole integration script: it caught a bad
    $($function:…) reference while this was being written, and nothing else in
    the suite would notice a script that fails to parse — the cost of one is the
    pane's whole prompt, git branch and shell state going quiet.
  • Both copies of the shell integration move together, as the mirror check
    requires.

Notes

  • No version bump.

derekneely and others added 3 commits August 20, 2026 07:29
`gh pr view --json state` answers OPEN / MERGED / CLOSED, and `report_pr`
carries that string through untouched. `SidebarMetadata.prStatus` is a plain
string while `WorkspaceInfo.prStatus` is the lowercase union, so the casing
difference has nothing to catch it on the way into the store, and
PrStatusIcon's switch — written against the union — runs off the end for every
real report. React 19 renders the implicit `undefined` as nothing, so the row
degrades quietly to a bare "#450 MERGED" with no glyph, which reads more like a
stuck badge than a merged one.

The fold onto the union lives next to the icon that needs it, as a small pure
function, so the render boundary is safe whatever a caller passes. The switch
also gains an explicit `null` default, so an unknown state can't go back to
drawing a silent blank. The badge text keeps its uppercase look via
`text-transform`, so nothing about the row's appearance changes except the icon
appearing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR metadata is workspace-scoped while every PowerShell pane in the workspace
runs its own poller, and they all write the same row. Two things follow from
that, both about which pane speaks for the row.

The first is that a `clear_pr` from one pane can take down a PR another pane
just reported — two panes, two repos, one workspace. The reporting surface is
now recorded alongside the PR (`prSurfaceId`), and a `clear_pr` is honoured
only from that surface. Tracking it shell-side instead would not be enough on
its own: a poller job cannot know another pane has since overwritten the row.

The second is the case the report opens with — closing the reporting pane
leaves the badge up. That one cannot be answered from the shell at all, since
a killed PTY runs no exit handler, so the badge is dropped where the surface
itself goes away: the same close chokepoints that already reap the PTY
(closeSurface, closePane, closeOtherSurfaces, closeSurfacesToRight). A surface
merely moved between panes keeps its badge; only a real close drops it, which
is pinned either way.

Once a clear is owner-gated, any path that destroys the owning surface without
clearing strands the badge for good — no other pane's clear is accepted either
— so the two remaining paths matter more than they did before. An agent spawn
with `--replace-tab` swaps out a pane's sole terminal and kills it directly
rather than going through closeSurface, so it now runs the same owner-gated
clear, exposed as a store action rather than duplicated. And `createWorkspace`
carries `prSurfaceId` alongside the PR fields already copied there, so a
workspace can never be created owning a badge it cannot clear.

A shell that dies inside a still-open tab (the user types `exit`, or the process
falls over) reaches none of those transitions — nothing calls closeSurface, the
tab is still there — so the badge is dropped from the `pty:exit` handler
instead, which is already where a stuck "Running" badge and a leftover progress
indicator are healed for the same reason. That signal arrives whether the shell
left gracefully or was killed, so it needs no help from the shell side.

The decision itself moves to a pure `applyPrCommand()`, next to the handler it
came from, so both paths are testable without the component tree — and the
normalization the icon already does is applied there too, at the point the
value enters the store.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ise one

`clear_pr` is handled in App.tsx but has no sender, unlike `clear_git_branch`,
whose clearing half is wired up in both integrations. The tick reports only on
the success path, so a `gh pr view` that finds nothing sends nothing and the
last reported PR stays on the row for the life of the workspace.

The job's location is the other half of it. `Start-Job` hands the child
runspace the location captured at call time and keeps it there. Starting the
poller on the shell's first idle is what keeps several hundred ms of runspace
startup off the first prompt and is worth keeping — but it does mean the
captured location is the one the pane opened in, so a pane that cd's into
another repo or worktree keeps being answered for the first one.

So the tick now decides through one pure function, `Get-WmuxPrMessage`, and the
prompt publishes the pane's live location for the job to read back each tick:

  in a repo, gh resolves a PR      → report_pr
  in a repo, gh finds nothing      → clear_pr, but only if this pane reported
  not in a repo                    → clear_pr, but only if this pane reported

Retracting only its own claim is what keeps a pane from speaking for panes it
knows nothing about: one workspace with a pane on a PR branch and a pane in `~`
would otherwise take turns reporting and clearing every 45 seconds. It also
means the pipe goes quiet once a badge is down, rather than carrying a
`clear_pr` forever. The renderer honours a clear only from the surface that
owns the row, so a retraction can never reach past this pane's own claim.

Walking out of a repo retracts for the same reason the branch does: the prompt
drops the branch off the row on the very next command, and a poller that stayed
quiet there would leave the PR sitting beside a row with no repo behind it.

Three tick-state edges come with it:

  - The "did I claim this" flag must only advance on a send that actually
    landed. Advancing it on the decision left a `clear_pr` that failed to go out
    (pipe not up yet, wmux busy, connect timeout) believing there was nothing
    left to retract — the badge this change exists to unstick, stuck again one
    step later. `Invoke-WmuxPrTick` leaves the flag where it was so the next
    tick recomputes and retries.

  - An unreadable cwd hand-off used to fall back to whatever location the
    runspace last held, then report that repo's PR as current — the frozen
    location again, in miniature. `Resolve-WmuxPaneCwd` answers null for every
    case where the pane's location cannot be trusted (file missing, empty,
    unreadable, or naming somewhere that is no longer a directory), and the tick
    then says nothing rather than answering for a pane that may have moved on.

  - The hand-off file outlives panes that are killed rather than closed: a kill
    runs no exit handler, so nothing removes theirs and the directory grows one
    small file per pane. Age separates the two, and a live pane keeps its own
    file young from both ends — the prompt rewrites it on every command, and its
    poller touches it on every tick, so a pane parked at an idle prompt for days
    still cannot be swept out from under itself. The sweep runs on the same
    first idle as the poller, so it stays off the first prompt too.

The cwd hand-off is a file rather than the pipe deliberately, and the reason is
recorded next to it: the pipe runs one direction, shell to wmux, and the
consumer here is another shell process. It lives in the temp directory
wmux-bash-integration.sh already uses for its own hand-off, and is removed on
exit. Both copies of the script move together, as the mirror check added in
1.1.1 requires.

Verified end to end on PowerShell 7 and Windows PowerShell 5.1; the decision
functions are exercised in a real host, including through an actual job's
initialization script, rather than asserted against the source text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amirlehmam
Copy link
Copy Markdown
Owner

Merged, and shipping in v1.6.0. Thank you — this is one of the best-argued reports this repo has had.

The casing bug is the kind I'd never have found by reading the code. PrStatusIcon looks correct in isolation, and React 19 turning a fall-off-the-end undefined into a silent blank meant the symptom was "the badge looks stuck" rather than anything that points at the switch. The explicit default: return null is the part I'm happiest about: it makes that whole class of bug loud the next time instead of invisible.

The two calls you left me

Both in favour of what you shipped — no changes on merge.

A merged PR keeps its badge. You're right that gh pr view resolving from the branch means it's refreshed, not stale, and now that the glyph actually draws, "this branch has landed" is genuinely useful on the row. Leaving it exactly as you have it.

A pane that walks out of a repo retracts its own badge. Same reasoning as yours — the prompt drops the branch off the row on the very next command, so a PR sitting beside a repo-less row reads as stuck. That it only ever retracts its own claim is what makes it safe to do at all.

On the unauthenticated-gh edge: agreed, one spurious clear beats parsing gh's stderr, which won't hold across versions. If it ever becomes a real annoyance in practice, gating on gh auth status is the right escape hatch — not worth an extra process spawn per tick today.

What I expect to reuse

prSurfaceId is the durable idea here. "Workspace-scoped field, written by N independent per-pane pollers" is a shape that exists elsewhere in the sidebar, and the row belongs to whoever claimed it is a better answer than anything the shell side could have managed on its own — a poller job genuinely cannot know another pane has since overwritten the row. Clearing it at the same chokepoints that already reap the PTY, plus pty:exit for a shell that dies inside a still-open tab, covers the paths I'd have missed. The --replace-tab spawn path in particular I would not have thought of: it kills the PTY directly rather than routing through closeSurface, so it would have left prSurfaceId pointing at a surface that no longer exists — an unclearable badge, forever.

Also appreciated the parse gate on the integration script. A script that fails to parse costs the pane its whole prompt, git branch and shell state, and nothing else in the suite would have noticed.

Verified on merge

  • npx vitest run — 1235 passed / 100 files (up from 1159 / 95 at v1.5.1)
  • npm run typecheck — clean
  • npm run lint — back at the master baseline
  • npm run verify:resources — both copies of the PowerShell integration confirmed in sync

One tiny follow-up commit of my own on top, unrelated to your work: #183 merged alongside this and moved buildDefaultSplitTree out of App.tsx, orphaning its uuid import.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0