refactor(ui): one store per fact, read by every surface #106

Merged
lz merged 7 commits from feat/store-consolidation into main 2026-09-04 17:24:57 +02:00
Owner

Sixth in the #96 stack — targets #101, not main. No new UI; this closes functional gaps left by the audit-fix pass, all of the same shape: one fact, several surfaces, more than one owner.

Session health moves into the store

Sessions.svelte owned the per-session claude probe. That component is unmounted for exactly as long as the collapsed rail is showing — so the rail's hover card could not report a live agent state, and menus opened from a rail segment or a dock tab had no answer at all.

Now on WorkspacesStore, driven from the tail of refresh() (past every await, so it can never register as a dependency of a caller's $effect). The 8s TTL, the status === 'running' filter and the plain-Map bookkeeping carry over with their reasoning; Sessions.svelte's untrack could be deleted rather than moved.

agentRunning() is three-valued on purpose — undefined means "not probed", which is not false.

menuContextFor reads the store

portCount and agentRunning were the last two fields still handed in by a caller, and only one of the four callers had them. So the rail, the dock tab and the workspace card all showed no count on the Ports row, and all three offered "Stop agent" for an agent that was already stopped. The extra parameter is deleted, not merely unused.

Browser-verified across all three surfaces:

session state menu
alpha running → probed → claudeRunning: false Ports 2, no "Stop agent"
beta stopped → never probed → undefined Ports 0, "Stop agent" present

One /api/quota poll

The stats store and QuotaBadge each polled it on their own 10s timer, for two halves of one payload. The poll moves into the store behind a subscriber refcount, because neither caller can drive the other: the badge renders on /settings where MainSplit is absent, and MainSplit renders in states where the header hides the badge.

Measured in a browser — request stamps on /: 281, 10282, 20281 ms. One timer. On /settings with MainSplit unmounted, the badge alone still polls at 10s.

The teardown path is pinned by five unit tests, each negative-controlled by breaking the guard it covers — including expected 6 to be 3, which is the two-polls-per-tick regression this removes. That path had never executed anywhere: a browser reaches "both mounted" trivially and "neither mounted" almost never.

Also

  • NewWorkerModal uses the shared workerImages store; load() coalesces onto the in-flight promise so the modal cannot observe a not-yet-populated list and pick the wrong default image.
  • menu-actions.ts and target-menu.ts are framework-free TypeScript and move to $lib/menu/. TargetMenu.svelte stays under components/.

Gates

117 files / 1145 tests passed
typecheck  4889 FILES  0 ERRORS  0 WARNINGS
lint       clean
Sixth in the #96 stack — **targets #101**, not `main`. No new UI; this closes functional gaps left by the audit-fix pass, all of the same shape: one fact, several surfaces, more than one owner. ## Session health moves into the store `Sessions.svelte` owned the per-session claude probe. That component is unmounted for exactly as long as the collapsed rail is showing — so the rail's hover card could not report a live agent state, and menus opened from a rail segment or a dock tab had no answer at all. Now on `WorkspacesStore`, driven from the tail of `refresh()` (past every `await`, so it can never register as a dependency of a caller's `$effect`). The 8s TTL, the `status === 'running'` filter and the plain-`Map` bookkeeping carry over with their reasoning; `Sessions.svelte`'s `untrack` could be deleted rather than moved. `agentRunning()` is three-valued on purpose — `undefined` means "not probed", which is **not** `false`. ## `menuContextFor` reads the store `portCount` and `agentRunning` were the last two fields still handed in by a caller, and **only one of the four callers had them**. So the rail, the dock tab and the workspace card all showed no count on the Ports row, and all three offered "Stop agent" for an agent that was already stopped. The `extra` parameter is deleted, not merely unused. Browser-verified across all three surfaces: | session | state | menu | |---|---|---| | `alpha` | running → probed → `claudeRunning: false` | `Ports 2`, **no "Stop agent"** | | `beta` | stopped → never probed → `undefined` | `Ports 0`, **"Stop agent" present** | ## One `/api/quota` poll The stats store and `QuotaBadge` each polled it on their own 10s timer, for two halves of one payload. The poll moves into the store behind a **subscriber refcount**, because neither caller can drive the other: the badge renders on `/settings` where `MainSplit` is absent, and `MainSplit` renders in states where the header hides the badge. Measured in a browser — request stamps on `/`: **281, 10282, 20281 ms**. One timer. On `/settings` with `MainSplit` unmounted, the badge alone still polls at 10s. The teardown path is pinned by five unit tests, each negative-controlled by breaking the guard it covers — including `expected 6 to be 3`, which is the two-polls-per-tick regression this removes. That path had never executed anywhere: a browser reaches "both mounted" trivially and "neither mounted" almost never. ## Also - `NewWorkerModal` uses the shared `workerImages` store; `load()` coalesces onto the in-flight promise so the modal cannot observe a not-yet-populated list and pick the wrong default image. - `menu-actions.ts` and `target-menu.ts` are framework-free TypeScript and move to `$lib/menu/`. `TargetMenu.svelte` stays under `components/`. ## Gates ``` 117 files / 1145 tests passed typecheck 4889 FILES 0 ERRORS 0 WARNINGS lint clean ```
lz added 2 commits 2026-09-04 00:08:21 +02:00
Five places where the same fact was fetched twice, or handed in by a
caller that happened to know it.

Session health moves into WorkspacesStore. Sessions.svelte owned the
sessionHealth probe, and that component is unmounted for exactly as long
as the collapsed rail is showing — so the rail's hover card could not
report a live agent, and the menus opened from a rail segment or a dock
tab had no answer at all. SettingsPanel had grown a second probe on the
same endpoint behind the same TTL to compensate. The probe now runs once,
off the tail of refresh(), with the TTL bookkeeping in a plain Map for
the reason the old comment gives.

menuContextFor no longer takes an `extra` argument. portCount and
agentRunning were the last two fields passed in by hand and only one of
four callers passed them, so three surfaces showed no count on the Ports
row and all three offered "Stop agent" for an agent that was already
stopped — the exact defect class the helper exists to prevent.

/api/quota is polled once, not twice. QuotaBadge and the stats store each
had a 10s timer for two halves of one payload. The poll moves into the
store behind a subscriber refcount, because the badge renders on pages
where MainSplit is not mounted and MainSplit renders where Header hides
the badge — neither can drive the other.

NewWorkerModal reads the workerImages store instead of fetching the list
a second time. load() now coalesces onto an in-flight request: the modal
is the only reader that branches on the list being EMPTY, and an early
return to a not-yet-populated list would drop the operator into Pull mode
with no default image.

menu-actions.ts and target-menu.ts are framework-free TypeScript and move
to $lib/menu/. TargetMenu.svelte stays under components/.

No visible behaviour changes beyond the two menu defects above.
test(stores): pin the /api/quota subscriber refcount
All checks were successful
ci / nexus (pull_request) Successful in 6m58s
ci / images (pull_request) Successful in 8m14s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 11s
020830c4c8
The refcount was the one part of the poll consolidation argued from the
code rather than observed. A browser reaches "both subscribers mounted"
trivially and "neither mounted" almost never — the only route to it is the
lock screen — so the branch that actually clears the interval had never
run under test or in a browser. A stranded interval polls forever and
surfaces nowhere.

Five cases, each negative-controlled by breaking the guard it covers:

- drop the `--count === 0` guard, so any leave clears the timer
  -> "keeps polling while ONE subscriber remains" fails
- drop the idempotent-leave guard
  -> "survives a double leave without stranding the timer" fails
- never clearInterval
  -> "STOPS polling when the last subscriber leaves" fails,
     "expected 7 to be 1"
- drop the `++count === 1` guard, so every subscribe starts a timer
  -> "runs ONE timer for two subscribers" fails, "expected 6 to be 3" —
     which is the two-polls-per-tick regression this change removed
lz changed target branch from feat/command-palette to main 2026-09-04 17:24:46 +02:00
lz merged commit 830567fdfb into main 2026-09-04 17:24:57 +02:00
lz referenced this pull request from a commit 2026-09-04 17:25:01 +02:00
Sign in to join this conversation.
No reviewers
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
lz/agent-nexus!106
No description provided.