Skip to content

feat(sessions): show a bulk restart running, and let it be stopped - #144

Merged
AThraen merged 2 commits into
mainfrom
feat/restart-progress-rail
Sep 30, 2026
Merged

AThraen merged 2 commits into
mainfrom
feat/restart-progress-rail

Conversation

@AThraen

@AThraen AThraen commented Sep 30, 2026

Copy link
Copy Markdown
Contributor

Why

Restart all… (#140) is strictly sequential, and each Claude session waits for its old process to actually exit before its replacement starts. On a large setup that is a minute-scale operation — measured against a 55-session state.json, roughly 3½–7 minutes — and until now nothing on screen said so, nothing showed progress, and the only way out was closing the app.

What

A rail, not the shutdown board. RestartRail (2px, docked under RestoreRail) plus a RestartPill counter in the toolbar's right stack, driven by SetRestartProgress.

ShutdownOverlay was the obvious thing to reuse and it is the wrong shape here. It only works because OnClosing collapses TerminalGrid before showing it — WebView2 is an HwndHost and composites over any WPF overlay regardless of Panel.ZIndex — and collapsing the grid during a restart would black out every live pane for the whole run. A bulk restart does not block the user, so it gets the quiet rail; shutdown does, so it keeps the board. Both rail and pill stay hidden for a single target, so the toolbar ↻ never flashes one.

The restart pair is separate from the restore pair rather than a shared rail. The OnLoaded restore loop leaves the UI responsive between awaits, so the sidebar quick menu's "Restart all…" is clickable while a restore is still running and the two loops can genuinely overlap; one shared rail would need a precedence rule for no benefit. Peach (#fab387, already the shutdown board's "closing…" colour) keeps the two counters legible when both are up.

Stop means "after the current one". The ⏹ on the pill sets _restartCancelRequested, read at the top of each loop iteration. It deliberately cannot abandon the session already mid-restart: by that point its VM is out of _vm.Sessions and its PTY is down, so returning early would strand it as dormant — the failure path, not a stop. Targets not yet reached keep their live terminals untouched, and a toast reports how many were skipped. The flag clears in the same finally as _restartInProgress.

The confirmation quotes a duration. New WPF-free Services/BulkRestartEstimate, following the ShellIntegrationPayload / SessionConfigEditor precedent so the wording is unit-testable at all. It costs a Claude target at 4–8s (CLAUDE.md's measured 2.3–4.7s exit + stagger + ~1.4s relaunch from the RESTORE log lines) and a plain shell at 1–2s, reports a range rather than false precision, and switches to minutes only once the optimistic bound passes 60s. At or above 10 targets it also states that restarts are sequential and names how many are Claude — that count is what makes the estimate look earned rather than arbitrary.

MessageBox cannot emphasise anything, so scale is carried by the words. Making the warning look like a warning would mean a themed window instead of a MessageBox; that is a deliberate non-goal here and noted in CLAUDE.md.

Testing

Built at 0 warnings; 609/609 unit tests pass, 12 of them new in BulkRestartEstimateTests.

The chrome was rendered and verified, not just compiled. A --clean instance was run with a temporary probe calling SetRestartProgress(12, 55) and the framebuffer sampled by pixel at x=400:

y=57  #313244   toolbar bottom border
y=58  #89B4FA   restore rail
y=59  #89B4FA
y=60  #FAB387   restart rail
y=61  #FAB387
y=62  #1E1E2E   app background

At x=1500 the peach has ended and only blue remains — each rail fills to exactly its k/N proportion. Both pills render side by side, restart left of restore. The probe is not in the diff.

Not verified: the ⏹ on a real multi-session restart, since driving session creation through UI automation does not currently work on this app (both InvokePattern.Invoke() and a real mouse click on NewSessionBtn fail to open the dialog while the process reports Responding=True). The stop path is exercised only by reading it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be

AThraen and others added 2 commits September 30, 2026 13:07
"Restart all…" on a large fleet is a minute-scale operation — restarts are
strictly sequential and each Claude session waits for its old process to
exit — and until now nothing on screen said so and nothing could stop it.
On a 55-session setup that is roughly 3½–7 minutes during which the only
exit is closing the app.

Three parts:

**A rail, not the shutdown board.** RestartRail (2px, docked under
RestoreRail) plus a RestartPill counter in the toolbar's right stack,
driven by SetRestartProgress. ShutdownOverlay only works because OnClosing
collapses TerminalGrid first — WebView2 is an HwndHost and composites over
any WPF overlay — and collapsing the grid here would black out every live
pane for the whole run. A bulk restart doesn't block the user, so it gets
the quiet rail; shutdown does, so it keeps the board. Hidden for a single
target, so the toolbar ↻ never flashes one.

The restart pair is separate from the restore pair rather than a shared
rail: the OnLoaded restore loop leaves the UI responsive between awaits, so
the quick menu's "Restart all…" is clickable mid-restore and the two loops
can genuinely overlap. Peach (#fab387, the shutdown board's "closing…"
colour) keeps the two counters apart when both are up.

**Stop means "after the current one".** The ⏹ sets _restartCancelRequested,
read at the TOP of each iteration. It cannot abandon the session already
mid-restart: its VM is out of _vm.Sessions and its PTY is down by then, so
returning early would strand it as dormant — the failure path, not a stop.
Targets not yet reached keep their live terminals untouched.

**The confirmation quotes a duration.** Services/BulkRestartEstimate is
WPF-free so the wording is unit-testable at all (the ShellIntegrationPayload
/ SessionConfigEditor precedent). It costs a Claude target at 4–8s (exit
wait 2.3–4.7s + stagger + ~1.4s relaunch) and a plain shell at 1–2s, reports
a range rather than false precision, and switches to minutes only once the
optimistic bound passes 60s. At 10+ targets it also says the restarts are
sequential and names how many are Claude — that count is what makes the
estimate look earned rather than arbitrary.

Verified: 0-warning build, 609/609 unit tests (12 new). The chrome was
rendered against a live --clean instance and sampled by pixel: below the
toolbar border at #313244, the restore rail reads #89B4FA and the restart
rail #FAB387, each filling to exactly its k/N proportion, with both pills
side by side in the toolbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
An independent review pass found seven things. All were real; six are fixed
here and the seventh is the reason for the biggest change.

**A restart can no longer start during the startup restore.** Both loops spawn
claude.exe and neither one's stagger can see the other's launches, so
overlapping them is the unlocked read-modify-write on ~/.claude.json that
ClaudeLaunchStaggerMs exists to prevent. The restore loop awaits per session
and leaves the quick menu clickable throughout, so "Restart all…" mid-restore
is an ordinary click on a 55-session start. Both RestartSessionAsync and
RestartSessionsAsync now refuse while _restoreInProgress, dropped with a toast
like the existing guard — one session is enough to lose the race.

This is the finding that should not have needed a reviewer: the previous
commit's XAML comment and CLAUDE.md paragraph both cited that overlap as the
*reason* the rails are separate pairs, which wrote a config race down as a
layout question. Both are corrected, and the separate pairs are now justified
on their own terms.

The rest:

- The confirmation promised a stop control that is never rendered. The bulk
  entry points confirm whatever the count, but SetRestartProgress hides the
  pill at total <= 1 — so at exactly one session the dialog pointed at
  something that never appears. Gated on totalTargets > 1.
- RestartStopBtn had no Style, so it fell through to Aero2's default Button
  template, whose IsEnabled=false trigger sets Background/BorderBrush/
  Foreground with TargetName and outranks both the Transparent TemplateBinding
  and the muted brush set in code. The disabled ⏹ would have rendered as a
  light-grey #F4F4F4 box inside the dark pill. ToolBtn has no disabled trigger.
- The stop toast counted vanished targets as "finished": close three
  not-yet-reached sessions mid-run, then stop, and three of the "finished"
  were never restarted. A separate counter now tracks work actually done, and
  "not restarted" replaces "left running" (a target an earlier failure dropped
  to dormant is neither).
- ConfirmText_SaysTheQueueCanBeStopped asserted Contains("stop"), which the
  always-present "...session commands are stopped" satisfied — deleting the
  whole affordance sentence left it green. Now asserts "stop the queue".
- DescribeDuration clamped `others` at 0 but never capped `claude` at the
  total, so (3 targets, 10 Claude) quoted 40-80 seconds for three sessions.
- The restart-failure MessageBox had no owner, so it could sit behind the main
  window while the bulk loop blocked in it — the rail frozen mid-count with
  nothing explaining why, which is the exact "reads as a hang" the rail exists
  to prevent. Now owned.
- RestartStop_Click guarded only on _restartInProgress, which the
  single-session path also holds, with stale _restartDone/_restartTotal from
  the previous bulk run. Not reachable (a Collapsed pill does not hit-test),
  but that made the handler's correctness rest on a visibility property;
  guarded on _restartTotal > 1 instead. _restartInProgress is also now the
  last statement before its try.

3 new tests (612 total), build clean at 0 warnings.

Not verified: the disabled ⏹ on screen. The enabled pill and both rails were
confirmed by pixel earlier; the disabled state could not be captured because
Windows' foreground lock blocked every attempt to raise the test window. The
fix is reasoned from the Aero2 template, not seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDsEfog5kVkT5NmX1Ya5be
@AThraen
AThraen merged commit 8ccb818 into main Sep 30, 2026
1 check passed
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.

1 participant