feat(ui): declutter the sidebar onto a shared target menu #100

Merged
lz merged 14 commits from feat/target-menu into main 2026-09-04 17:24:35 +02:00
Owner

Stacked on #99, which is stacked on #98. Merge in order. Third of the four PRs in the issue #96 spec — designs D3, D4, D5, D6, D9, D10, D11.

Seven commits, each independently green, in the order they build on each other.

What changed

A session row is 2 hit targets, down from 5. The name opens the terminal — the one action worth a first-class target — and (or right-click anywhere on the row) opens everything else. Deleted: ViewButtons, the five-button .card-actions row, the inline <Previews> block under every session, the per-row ×, and the card's duplicated mounts readout.

The workspace shell moved to the top of its card. That fixes the naming complaint at its root: start agent was ambiguous because it sat on the workspace header next to Start/Stop (the container). It's now Start/Stop shell agent on the row it controls, and container power is Start/Stop workspace in the workspace menu.

Ports and Settings became dock panels. DockView gains both. Settings is a panel and not a modal deliberately — a modal blocks the terminal, and editing an env var while watching the agent restart is a normal thing to want.

Identity is a dim wash; the left border means status alone. Three of the eight SESSION_PALETTE hues are byte-identical to --running / --warn / --danger, so the two channels could read as each other. sessionColourRgb() is derived from the hex palette, with a test pinning them together. Two alphas (--wash-row 0.10, --wash-tile 0.22) because a wash that reads on a 330px row disappears on a 40px rail tile.

The rail is segmented capsules with a hover card. One bordered column per workspace; the shared border is what makes a session read as belonging to it. Measured 37 × 24 = 888px² per segment, against 81px² for the dot it replaces, and clear of WCAG 2.2 SC 2.5.8's 24×24 on both axes — which the old dot failed outright. The hover card opens on :focus-visible too; the segments are buttons and a keyboard user got nothing at all before.

Right-click a dock tab gets the same menu, so collapsing the sidebar is lossless.

Mobile gets a panels pill. Reaching the dock used to require opening a panel — openPanel() was showDock()'s only caller, so forward always mutated the layout while coming back was free.

Issue #95, absorbed

No confirm on revoke · operator-created ports auto-approve (approval gates the agent callback; a port typed into the operator's own UI has already expressed that intent) · the remapped host port is shown when it differs from the requested one.

Verification

pnpm test 110 files / 1049 tests · pnpm run typecheck 4864 files, 0 errors · pnpm run lint clean.

Browser-driven against a seeded instance, since the suite is node-only and proves nothing about rendering:

checked result
sidebar audit 0 view icons, 0 inline preview blocks, 1 ports chip (only the session that has one)
session row 2 buttons (3 with a ports chip)
menu portalled to <body>, clamped in viewport, live port count in the hint
Ports panel renders, 1 preview row, real host URL
Settings panel renders; env/mounts labelled — set on lz/agent-nexus
tab right-click menu opens with ✓ on the docked view
rail 3 capsules, segment 37×24 = 888px², wash on docked segments, progress segment, warn pip
hover card portalled, in viewport, opens on keyboard focus
mobile 390px pill visible with identity dots, switches to the dock without opening a panel, list scrolls
long-press full-width bottom sheet with dim scrim

Deliberately not included

  • "Restart agent" in the session menu. There is no restart-session endpoint, and a menu row that does nothing is worse than an absent one.
  • A workspace submenu on session tabs. The tab variant carries views + Close + Close others only: removing a session from under one of its own open panels isn't something to offer at the pointer, and a tab has no sensible place for container controls.
  • Editable env/mounts in the Settings panel. Both tables are keyed on repo_full_name, so an editable table under a "workspace" heading would silently reconfigure siblings.

Two preview tests pinned the pre-#95 behaviour and were updated rather than worked around, plus a new case for a bind failure degrading to pending instead of failing the create.


Added after the spec audit

A five-agent pass over the shipped code against the spec and the mock found this PR carried the bulk of what was missing. All of it is now here.

The blocking criterion was false. "Every action reachable from the sidebar is reachable from a dock tab's context menu" — the tab menu was a hand-written fourth shape with four views plus Close, so ~10 actions had no route. The cause was three near-identical onPick switches, each knowing a different subset of MenuAction. menu-actions.ts is now the only dispatcher, and the two -tab variants are composed from the sidebar's own item builders, so the criterion holds by construction. target-menu.test.ts pins the superset relation across all four toggled-row combinations — verified by rebuilding session-tab by hand and watching it go red.

Session-agent lifecycle did not exist product-wide, which is why the menu had no Restart/Stop and D6 had no Agent section. POST/DELETE on sessions/:id/agent now restart and stop claude without touching the row or the worktree. Stop respawns the tmux window as bash rather than killing it, because convergeSessions restores a session whose window is missing — killing it would be silently undone within one 10s poll.

D6 was a stub — two links, zero <button>. It now carries live agent state with Restart/Stop, the agent-teams toggle, created + usage, image + capability chips, container state with Start/Stop, the repo's env keys, and both Remove actions.

WorkerDTO had no worker_image. One missing field blocked three surfaces; the rail's hover card row labelled "image" printed container_name.

The rail had no context menu, against Decision 2 — leaving the collapsed sidebar able to open terminals and nothing else.

Also: the hover card gains usage and docked views; the mobile pill's 4-dot cap now shows an overflow dot instead of lying about the count; the edge swipe (D11) is implemented with its thresholds in a pure, tested module; the ports panel body says Ports rather than Previews; and /api/quota stopped being fetched once per workspace card.

SessionRow and ProvisionProgress come out of the two sidebar files, which is what finally makes their net line count fall (662 → 627) as the spec requires, rather than rise.

Browser-verified at 1400px and 390px: both tab menus (12 and 15 rows, no duplicates), the rail menu, "New session…" from the collapsed rail expanding and focusing the right form, both settings scopes, the corrected image row, the overflow dot, and the edge swipe in both directions plus its scroll guard.

Verified in a real container, and reviewed

Ran the session-agent endpoints against a live worker (real image, real clone, real claude). Stop → converge → restart behave as designed: the window survives a stop as bash, convergeSessions correctly leaves it alone, and restart reuses the same window with --resume on the same conversation id.

That run found a pre-existing bug the suite could not see: the per-session health probe is /bin/sh -c "<script>" that greps every proc cmdline for --remote-control <workspace · session> — and the script text contains that pattern, so it matched its own entry and exited 0. A session name that had never existed reported claude as running. Invisible on main because nothing could produce the state it hides in; stop is exactly that state. Now matched by CWD.

Then /simplify (4 agents) and /pr-review (2 adversarial). What they caught, all fixed:

  • setSessionAgent dropped -e NEXUS_SESSION_ID — found independently by two agents. tmux does not remember a window's original -e, so a restarted session lost notify-preview / notify-artifact permanently (fact #16).
  • respawn-window -t nexus:<name> resolves an all-digit name as an INDEX — verified against real tmux 3.3a. Restart on a session named 1 SIGKILLs a different session's claude and leaves it dead where convergeSessions will never look. Same hole in deleteSession's kill-window, older and worse. Both now resolve a window ID.
  • Stop left sessions.status = 'running', so the rail showed green while the expanded sidebar showed a red error for the same deliberately-stopped agent.
  • Restart claimed to resume a conversation it had notresumeFlag emits nothing for the empty id migration 0012 backfilled, so an upgraded install silently got a fresh conversation.
  • Neither action waited for claude; both now do, and return {confirmed} so the toast can say "not up yet".
  • The workspace-tab menu offered open-shell twice, and my guard test compared labels — so did my manual browser check, because I wrote both.
  • Silent failures: goto outside its try, "Copy branch name" returning quietly with a stale clipboard, "New session…" on a booting workspace doing nothing visible, a failed env fetch rendering as "None set", and a transient image-list failure latched for the life of the page.
  • A session Settings panel restored from localStorage rendered the workspace pane — with a live "Remove workspace…" — during the window between listWorkers and listSessions.

22 tests added for the endpoints and the dispatcher, plus 5 for the review findings. Every guard was confirmed to go red against the behaviour it describes.

**Stacked on #99**, which is stacked on #98. Merge in order. Third of the four PRs in the issue #96 spec — designs D3, D4, D5, D6, D9, D10, D11. Seven commits, each independently green, in the order they build on each other. ## What changed **A session row is 2 hit targets, down from 5.** The **name** opens the terminal — the one action worth a first-class target — and **⋯** (or right-click anywhere on the row) opens everything else. Deleted: `ViewButtons`, the five-button `.card-actions` row, the inline `<Previews>` block under every session, the per-row `×`, and the card's duplicated mounts readout. **The workspace shell moved to the top of its card.** That fixes the naming complaint at its root: `start agent` was ambiguous because it sat on the workspace header next to `Start`/`Stop` (the container). It's now `Start/Stop shell agent` on the row it controls, and container power is `Start/Stop workspace` in the workspace menu. **Ports and Settings became dock panels.** `DockView` gains both. Settings is a panel and not a modal deliberately — a modal blocks the terminal, and editing an env var while watching the agent restart is a normal thing to want. **Identity is a dim wash; the left border means status alone.** Three of the eight `SESSION_PALETTE` hues are byte-identical to `--running` / `--warn` / `--danger`, so the two channels could read as each other. `sessionColourRgb()` is *derived* from the hex palette, with a test pinning them together. Two alphas (`--wash-row` 0.10, `--wash-tile` 0.22) because a wash that reads on a 330px row disappears on a 40px rail tile. **The rail is segmented capsules with a hover card.** One bordered column per workspace; the shared border is what makes a session read as belonging to it. **Measured 37 × 24 = 888px² per segment, against 81px² for the dot it replaces**, and clear of WCAG 2.2 SC 2.5.8's 24×24 on both axes — which the old dot failed outright. The hover card opens on `:focus-visible` too; the segments are buttons and a keyboard user got nothing at all before. **Right-click a dock tab** gets the same menu, so collapsing the sidebar is lossless. **Mobile gets a panels pill.** Reaching the dock used to *require* opening a panel — `openPanel()` was `showDock()`'s only caller, so forward always mutated the layout while coming back was free. ## Issue #95, absorbed No confirm on revoke · operator-created ports auto-approve (approval gates the *agent* callback; a port typed into the operator's own UI has already expressed that intent) · the remapped host port is shown when it differs from the requested one. ## Verification `pnpm test` 110 files / **1049 tests** · `pnpm run typecheck` 4864 files, 0 errors · `pnpm run lint` clean. Browser-driven against a seeded instance, since the suite is node-only and proves nothing about rendering: | checked | result | |---|---| | sidebar audit | 0 view icons, 0 inline preview blocks, 1 ports chip (only the session that has one) | | session row | 2 buttons (3 with a ports chip) | | menu | portalled to `<body>`, clamped in viewport, live port count in the hint | | Ports panel | renders, 1 preview row, real host URL | | Settings panel | renders; env/mounts labelled `— set on lz/agent-nexus` | | tab right-click | menu opens with ✓ on the docked view | | rail | 3 capsules, segment 37×24 = 888px², wash on docked segments, progress segment, warn pip | | hover card | portalled, in viewport, **opens on keyboard focus** | | mobile 390px | pill visible with identity dots, switches to the dock **without opening a panel**, list scrolls | | long-press | full-width bottom sheet with dim scrim | ## Deliberately not included - **"Restart agent"** in the session menu. There is no restart-session endpoint, and a menu row that does nothing is worse than an absent one. - **A workspace submenu on session tabs.** The tab variant carries views + Close + Close others only: removing a session from under one of its own open panels isn't something to offer at the pointer, and a tab has no sensible place for container controls. - **Editable env/mounts in the Settings panel.** Both tables are keyed on `repo_full_name`, so an editable table under a "workspace" heading would silently reconfigure siblings. Two preview tests pinned the pre-#95 behaviour and were updated rather than worked around, plus a new case for a bind failure degrading to pending instead of failing the create. --- ### Added after the spec audit A five-agent pass over the shipped code against the spec and the mock found this PR carried the bulk of what was missing. All of it is now here. **The blocking criterion was false.** "Every action reachable from the sidebar is reachable from a dock tab's context menu" — the tab menu was a hand-written fourth shape with four views plus Close, so ~10 actions had no route. The cause was three near-identical `onPick` switches, each knowing a different subset of `MenuAction`. `menu-actions.ts` is now the only dispatcher, and the two `-tab` variants are **composed** from the sidebar's own item builders, so the criterion holds by construction. `target-menu.test.ts` pins the superset relation across all four toggled-row combinations — verified by rebuilding `session-tab` by hand and watching it go red. **Session-agent lifecycle did not exist** product-wide, which is why the menu had no Restart/Stop and D6 had no Agent section. `POST`/`DELETE` on `sessions/:id/agent` now restart and stop claude without touching the row or the worktree. Stop respawns the tmux window as bash rather than killing it, because `convergeSessions` restores a session whose window is *missing* — killing it would be silently undone within one 10s poll. **D6 was a stub** — two links, zero `<button>`. It now carries live agent state with Restart/Stop, the agent-teams toggle, created + usage, image + capability chips, container state with Start/Stop, the repo's env keys, and both Remove actions. **`WorkerDTO` had no `worker_image`.** One missing field blocked three surfaces; the rail's hover card row *labelled* "image" printed `container_name`. **The rail had no context menu**, against Decision 2 — leaving the collapsed sidebar able to open terminals and nothing else. Also: the hover card gains usage and docked views; the mobile pill's 4-dot cap now shows an overflow dot instead of lying about the count; the edge swipe (D11) is implemented with its thresholds in a pure, tested module; the ports panel body says Ports rather than Previews; and `/api/quota` stopped being fetched once per workspace *card*. `SessionRow` and `ProvisionProgress` come out of the two sidebar files, which is what finally makes their net line count **fall** (662 → 627) as the spec requires, rather than rise. Browser-verified at 1400px and 390px: both tab menus (12 and 15 rows, no duplicates), the rail menu, "New session…" from the collapsed rail expanding and focusing the right form, both settings scopes, the corrected image row, the overflow dot, and the edge swipe in both directions plus its scroll guard. ### Verified in a real container, and reviewed Ran the session-agent endpoints against a live worker (real image, real clone, real claude). Stop → converge → restart behave as designed: the window survives a stop as `bash`, `convergeSessions` correctly leaves it alone, and restart reuses the same window with `--resume` on the same conversation id. That run found a **pre-existing bug the suite could not see**: the per-session health probe is `/bin/sh -c "<script>"` that greps every proc cmdline for `--remote-control <workspace · session>` — and the script text *contains* that pattern, so it matched its own entry and exited 0. A session name that had never existed reported claude as running. Invisible on `main` because nothing could produce the state it hides in; stop is exactly that state. Now matched by CWD. Then `/simplify` (4 agents) and `/pr-review` (2 adversarial). What they caught, all fixed: - **`setSessionAgent` dropped `-e NEXUS_SESSION_ID`** — found independently by two agents. tmux does not remember a window's original `-e`, so a restarted session lost `notify-preview` / `notify-artifact` permanently (fact #16). - **`respawn-window -t nexus:<name>` resolves an all-digit name as an INDEX** — verified against real tmux 3.3a. Restart on a session named `1` SIGKILLs a *different* session's claude and leaves it dead where `convergeSessions` will never look. Same hole in `deleteSession`'s `kill-window`, older and worse. Both now resolve a window ID. - **Stop left `sessions.status = 'running'`**, so the rail showed green while the expanded sidebar showed a red error for the same deliberately-stopped agent. - **Restart claimed to resume a conversation it had not** — `resumeFlag` emits nothing for the empty id migration 0012 backfilled, so an upgraded install silently got a fresh conversation. - **Neither action waited for claude**; both now do, and return `{confirmed}` so the toast can say "not up yet". - The `workspace-tab` menu offered `open-shell` **twice**, and my guard test compared *labels* — so did my manual browser check, because I wrote both. - Silent failures: `goto` outside its try, "Copy branch name" returning quietly with a stale clipboard, "New session…" on a booting workspace doing nothing visible, a failed env fetch rendering as "None set", and a transient image-list failure latched for the life of the page. - A session Settings panel restored from localStorage rendered the **workspace** pane — with a live "Remove workspace…" — during the window between `listWorkers` and `listSessions`. 22 tests added for the endpoints and the dispatcher, plus 5 for the review findings. Every guard was confirmed to go red against the behaviour it describes.
lz added 7 commits 2026-09-02 18:47:48 +02:00
Two different things claimed a card's left edge: status (running / stopped /
booting / errored) and identity ("these panels are docked, and this is their
tab colour"). Worse than crowding, three of the eight SESSION_PALETTE hues --
#47ff8a, #ffb347, #ff6b6b -- are byte-identical to --running / --warn /
--danger, so the two channels could read as each other.

Identity moves to a background wash on the ROW (and the dock tab), leaving the
border to status. The status dot's fill is never recoloured, so the signals no
longer compete for the same pixels.

Two alphas, not one: --wash-row 0.10 reads on a 330px sidebar row and
disappears entirely on a 40px rail tile, which needs ~0.22. One global value
cannot serve both surfaces.

sessionColourRgb() is DERIVED from SESSION_PALETTE rather than written out a
second time -- a parallel hand-maintained list would drift silently, and the two
must agree by construction because they identify the same target. A test pins
them together.

A wash is a fill, and the sidebar already fills for hover, so hover on a washed
row becomes a border change instead of a third fill.

Also promotes the .bar track/fill to app.css: QuotaBadge already had the same
class name and the same track+<i> idiom, so the card was the second copy, not
the first.
The load-bearing piece of the sidebar declutter. `buildMenu` is pure and
framework-free, so the three menu shapes (session / workspace shell / workspace)
are unit-tested without mounting anything; TargetMenu.svelte only renders what
it returns.

The four views come first in a fixed order in every shape, and an already-docked
view is marked with a check -- that is the signal the lit icon buttons carried,
which is what makes removing them from the row possible.

DockView gains `ports` and `settings`, since both are panels rather than
modals. Their renderers land in the next commits.

Two things the component has to get right:

- It is PORTALLED to <body>, via a real DOM move rather than `position: fixed`.
  Fixed escapes ancestor `overflow` clipping, but any ancestor with a transform
  becomes its containing block -- and both surfaces this opens from have one:
  dockview transforms its panels, and `.right-pane` is translated on mobile.
  Inside those, a fixed menu is positioned against the wrong origin and clipped
  again.
- It is placed AFTER mount, once it has a measured size to clamp against the
  viewport, and stays hidden until then rather than rendering at 0,0 for a
  frame. Opened near an edge, an unclamped menu is one whose last items cannot
  be reached.

Under 768px it renders as a bottom sheet instead: a long-press has no cursor to
anchor a popup to.
Ports leaves the sidebar. The preview list previously rendered inline under
every session row -- a form, a row per port, and a URL, repeated per session
whether or not anything was forwarded -- which is most of what made the sidebar
heavy. Ports are keyed on a session row, and the workspace shell IS one
(kind='workspace_shell'), so a workspace-scoped panel resolves to the shell's
id rather than having no target.

Settings is a panel and not a modal on purpose: a modal blocks the terminal
behind it, and editing an env var while watching the agent restart is a normal
thing to want.

Its Environment and Mounts sections are READ-ONLY, and say why. Both tables are
keyed on repo_full_name, not on a worker, so neither is workspace-local -- and
they are not both "defaults" either: editing env pushes into every RUNNING
workspace on that repo immediately, while mounts are frozen onto
workers.mounts_json at container create and reach only the next one. An editable
table under a "workspace" heading would silently reconfigure siblings.

Picks up three of issue #95's asks:

- No confirm on revoke. It tears down a listener, destroys no work, and
  re-adding is one field away -- a modal was friction on a reversible action.
- Operator-created ports auto-approve. Approval exists to gate the AGENT
  callback, where something inside a container asks Nexus to open a listener on
  the operator's machine. An operator typing a port into their own UI has
  already expressed that intent; asking them to then approve their own request
  is a confirmation of the thing they just did. It reuses approvePreview rather
  than inlining a second insert path, so host-port selection and the proxy bind
  stay in one place, and a bind failure degrades to a pending row instead of
  failing the create.
- The remapped host port is shown when it differs from the requested one, so a
  URL that doesn't match what the dev server printed explains itself.
A session row was five hit targets -- the name, three view icons and a remove
button -- with a whole preview list rendered underneath it. It is now two: the
NAME (which opens the terminal, the one action worth a first-class target) and
⋯. Right-click anywhere on the row does the same as ⋯.

The workspace shell moves to the TOP of the card as its own row. That is what
fixes the naming complaint at its root: "start agent" was ambiguous because it
sat on the workspace header next to Start/Stop (the container). It is now
"Start/Stop shell agent" on the row it controls, and container power is
"Start/Stop workspace" in the workspace menu.

Deleted: ViewButtons (its icon set survives inside the menu), the five-button
.card-actions row, the inline <Previews> block under every session, and the
per-row × button. Retry is the one action that stays a button -- it is the only
thing an errored card can do, and burying the sole recovery path in a menu
would be perverse.

The ports chip renders only when a port is actually forwarded, where the inline
list rendered a heading and a form on every row regardless.

NOT included, deliberately: a "Restart agent" item. There is no restart-session
endpoint, and a menu row that does nothing is worse than an absent one.

Two preview tests pinned the pre-#95 behaviour (manual previews landing
pending). Updated rather than worked around, plus a new case for the bind
failure degrading to pending instead of failing the create.
Once anything is docked, switching views no longer requires going back to the
sidebar -- which is what makes collapsing it lossless.

Cheap because SessionTab already receives kind/workerId/sessionId/basePath and
resolves them with scopeFromParams(), which IS the ExplorerScope the sidebar
menu takes. DockDeps gains openPanel / isViewOpen / closeOtherPanels so a tab
can act on its own target.

The tab variant deliberately carries no lifecycle or destructive items:
removing a session from under one of its own open panels is not something to
offer at the pointer, and a tab has no sensible place for the workspace's
container controls. Views + Close + Close others, and nothing that would be
dead.
RAIL. One bordered capsule per workspace: initials on top, then one zero-gap
segment per target. The shared border is what makes a session read as belonging
to its workspace -- the loose 9px dots said nothing about who owned them, and at
81px² were barely clickable. A segment is 40×24 = 960px². The rail is
desktop-only (MainSplit gates it on isDesktop), so the bar is WCAG 2.2's 24×24
minimum, which 24px segments meet exactly and the 9px dot failed outright.

The capsule's left border carries STATUS, matching the wash carrying identity
everywhere else. A booting workspace gets a progress segment so the one status a
collapsed rail must not hide stays visible, and a warn pip marks anything
waiting on the operator.

HOVER CARD, replacing a one-line `${kind} ${name}` tooltip that told the
operator less than the row it replaced -- so collapsing the sidebar cost
information, not just space. Three things it had to get right:
  - opens on focus as well as hover; the segments are buttons and a keyboard
    user got nothing at all before
  - portalled to <body>, because the rail scrolls and its overflow clips
    anything past 52px
  - ONE timer shared across segments, so sliding down the rail does not re-arm
    a full delay at every stop

PILL. Reaching the dock on mobile used to REQUIRE opening a panel, since
openPanel() was showDock()'s only caller: forward always mutated the layout
while coming back was free. The pill is the missing free direction. It renders
only when something is docked, so it is never a control leading nowhere, and it
carries identity-coloured dots rather than a numeral -- the colours say whose
panels are waiting, which a count cannot.
fix(ui): tab icons for the new views, and drop the card's mounts readout
All checks were successful
ci / nexus (pull_request) Successful in 8m26s
ci / images (pull_request) Successful in 9m42s
3a19a5dd87
The browser pass caught both. A ports or settings tab fell through the icon
chain to the artifacts glyph, so three different views wore the same icon. And
the card still listed every mount plus "change → remove & re-create", which the
Settings panel now owns and states more precisely -- saying it twice is exactly
what the declutter is meant to stop.
refactor(ui): apply /simplify + review — plus five defects they found
All checks were successful
ci / nexus (pull_request) Successful in 7m34s
ci / images (pull_request) Successful in 9m3s
9fe5354e8f
Five real defects, four of them invisible to the suite:

- The workspace card's ⋯ button rendered UNSTYLED. `.icobtn` lived only in
  Sessions.svelte's scoped <style>, so it compiled to `.icobtn.svelte-<hash>`
  and never matched Workers' button. Hoisted to app.css, which is where a
  two-component class belongs.
- The identity wash never painted on the collapsed rail. `.seg`'s
  `background:` SHORTHAND resets background-image, and the scoped rule outranks
  the global `.washed-tile` (two classes vs one), so the rail showed no colour
  at all. Now `background-color`.
- The ⋯ menu could not be closed with Escape, and a keyboard user could not
  reach its items. `el.focus()` ran while the menu was still
  `visibility: hidden` — focus() on a hidden element is a silent no-op — so
  focus stayed on the trigger. Focus after the second tick, and hand it back to
  the invoker on close (the spec asked for both).
- The Ports panel never refreshed. It declared a `tick` prop dockview cannot
  supply (params are serialized scalars, frozen at create), so Previews loaded
  once and a port registered by notify-preview never appeared. `tick` now rides
  on DockDeps, the live proxy, assigned in Dock's existing $effect.
- Stopping the shell agent made the workspace's OWN views unreachable.
  closeWorkspaceSession deletes the shell session row, the shell row was gated
  on that row existing, and the workspace card's menu carries no view items —
  while the ViewButtons this replaced deliberately kept Files working with no
  agent. The shell row now always renders, reading "agent stopped".

Menus also advertised Shift-F / Shift-A / Ctrl-W, which nothing binds; the
hints return with the Ctrl-K palette that implements them. The rail's hover
card promised a right-click menu the rail does not have.

Simplifications:

- One `portal` action in $lib/actions (was three byte-identical copies, each
  with a different comment explaining the same trap).
- `.dock-empty` to app.css (six copies, already two variants).
- `buildMenu(variant, ctx)` — `scope` was read only by its own default-argument
  expression, and dragged a required prop through TargetMenu for nothing.
- `sessionColourRgb` looks up from `sessionColour` instead of re-running the
  hash; agreement is now structural rather than asserted by a test.
- `washVar(scope, on)` replaces the same template at four call sites, and the
  rail no longer calls hasPanels twice per segment.
- `workspaces.openShell()` — the card menu and the shell row had byte-identical
  copies, toast strings included.
- `hasPanels` derives from `openTargetKeys` rather than walking panels twice.
- DockView's set is declared ONCE, and Dock's factory and SessionTab's icons are
  `Record<DockView, …>`: a sixth view is now a compile error, where it threw at
  runtime and silently rendered the artifacts glyph. panel-target.test.ts
  iterates VIEWS — it had gone stale at three of five views.
- Dead: MenuContext.envCount (no caller ever set it), WorkspaceRail's isOpen
  prop, RailHoverCard's duplicate mount-time place(), and a full-page
  location.href where goto() keeps the terminals connected.

Verified in a browser against a seeded instance: menu contents per variant,
portalling, Escape/scrim dismissal and focus return, the wash painting at both
alphas on all four surfaces, a preview inserted mid-session appearing without
interaction, the mobile bottom sheet and panels pill, and the sidebar still
scrolling on a phone.
lz force-pushed feat/target-menu from 9fe5354e8f
All checks were successful
ci / nexus (pull_request) Successful in 7m34s
ci / images (pull_request) Successful in 9m3s
to c07700ad40
All checks were successful
ci / nexus (pull_request) Successful in 8m40s
ci / images (pull_request) Successful in 9m18s
2026-09-03 01:01:54 +02:00
Compare
fix(sessions): the health probe was matching itself, not claude
All checks were successful
ci / nexus (pull_request) Successful in 9m31s
ci / images (pull_request) Successful in 10m45s
3e594a975b
Found by running the new stop/restart endpoints against a real container, which
is the first thing that can produce the state the bug hides in: a live worker,
the tmux window present, and no claude in it.

The probe was `/bin/sh -c "<script>"` where the script greps every
`/proc/*/cmdline` for `--remote-control <workspace · session>`. The script text
CONTAINS that pattern, so the loop reached its own `/proc` entry and exited 0.
Measured in the container: a session name that had NEVER existed reported claude
as running, and so did a session whose agent had just been stopped.

Pre-existing — but invisible until now, because nothing could previously make a
running container hold a present window with no claude. Stopping an agent is
exactly that state, so shipping stop without this means the action succeeds and
then the UI reports the agent as running, on the sidebar dot and in the new
settings panel's Agent chip.

Matched by CWD instead, the same shape `probeClaudeWorkspaceProcess` already
used and immune for the same reason: the first 7 bytes of the probe's own
cmdline are `/bin/sh`, and its cwd is not a worktree. Still per-session — two
sessions in one worker have different worktree names — which is the property
fact #5 wanted from the argv match.

Verified against a real container before and after: `claudeRunning` now goes
true → false → true across stop and restart, and the reason names the worktree.

Tests for both new endpoints come with it, plus the dispatcher's. The
`setSessionAgent` set pins the three decisions that are easy to undo: `-k` (or
respawn refuses), reuse of the window rather than `new-window`, and `--resume`
rather than the bare id — that last assertion was too weak until a negative
control showed `resume: false` emits `--session-id <same id>`, which carries the
id while telling claude to start a fresh conversation under it.

`menu-actions.test.ts` derives its action list from `buildMenu` across every
variant rather than hand-listing it, so an action a menu can offer but the
dispatcher ignores fails the suite. A hand-listed set is exactly what let the
dock tab ignore twelve of fifteen actions while everything stayed green.
refactor: apply /simplify — including a regression two reviewers caught
All checks were successful
ci / nexus (pull_request) Successful in 8m54s
ci / images (pull_request) Successful in 10m9s
45f2b31106
Four cleanup agents over the audit-fix commits. Two of them independently found
the same functional regression, which is the one worth reading:

**`setSessionAgent` dropped `-e NEXUS_SESSION_ID`.** tmux builds a spawned
pane's environment from the server environment plus the `-e` arguments and does
NOT remember a window's original ones, so a respawn has to re-pass it —
`openWorkspaceSession` already does, on its own respawn, which was the
codebase's own evidence. Without it, `notify-preview` and `notify-artifact`
stop working in that session after a Restart (fact #16), silently and until the
session is recreated. The stop path had the same hole for the bash it leaves
behind. Fixed by pulling the launch out into a shared `spawnSessionPane` rather
than by adding the flag in a fourth place; the test now pins the env block, and
removing it goes red.

**`workspace-tab` offered `open-shell` twice** — as "Start shell agent" and as
"Open workspace shell", two rows firing one action under different names. The
guard test compared LABELS, so it passed; my own browser check compared labels
too, so I confirmed the wrong thing and reported no duplicates. The variants now
compose through `dedupeByAction`, the `withSettings` flag that patched the one
earlier collision is gone, and the test compares actions across all four toggle
states.

Also:

- "Session log" is deleted, not fixed. It opened the same terminal panel as the
  row four above it, minus the ✓ that says the panel is already docked. There is
  no separate transcript: the agent's log IS its tmux window.
- The repo-env effect in `SettingsPanel` was a poller. `workspaces.workers` is
  reassigned to a fresh array every tick, so an effect reading
  `worker?.repo_full_name` re-ran forever; it now derives the string first and
  only runs for the scope that renders it.
- The panel's health probe gets the same 8s TTL `<Sessions>` uses (both poll the
  same session when the sidebar is open), stops re-probing straight after a
  restart (the respawn does not wait for claude, so it painted "stopped"), and
  its Restart/Stop go through the dispatcher — which is also how Stop acquired
  the confirm it was missing while the identical menu action had one.
- Toggling agent-teams no longer refetches the whole fleet to reflect a boolean.
- `MenuActionHost`'s sidebar members are required; the two toast fallbacks no
  caller could reach are gone. `closePanel`/`closeOthers` stay optional and the
  header now says plainly that they are silent, instead of claiming everything
  degrades to a toast.
- One `probeClaudeCwd` for both probes, run concurrently — neither gates the
  other and both are docker execs. `reload()` likewise stops serialising two
  independent refreshes.
- Smaller: `readJsonBody`, `isRunning` and `WORKTREES_ROOT` instead of fourth
  copies; a dead `sessionStats.get()`; a separator that rendered above the first
  row of the workspace menu; Dock's sham `menuHost` seed replaced by a getter;
  and the restart/stop JSDoc moved onto the function it describes.
fix(sessions): apply /pr-review — a tmux target that hits the wrong window
All checks were successful
ci / nexus (pull_request) Successful in 8m40s
ci / images (pull_request) Successful in 9m9s
pr-image-cleanup / delete-pr-images (pull_request) Successful in 9s
3c05a7c6d3
Two adversarial reviewers over the audit-fix commits. The critical one is
verified against real tmux, not reasoned:

**`respawn-window -t nexus:<name>` resolves an all-digit name as an INDEX.**
tmux tries the window half of a target as an index before it tries it as a
name, and `1` / `42` are legal git refs the create form accepts. Measured on
3.3a with windows `0:workspace 1:alpha 2:beta 3:"1"`, `-t nexus:1` resolves to
**alpha** — and the `=` exact-match prefix does not change it. So Restart on a
session named `1` SIGKILLs alpha's claude, relaunches session 1's agent inside
alpha's window (where notify-preview then attributes to the wrong session), and
leaves alpha dead permanently: convergeSessions only restores a session whose
window is MISSING, and alpha's window is right there.

Now resolved to a window ID first. The same hole existed in `deleteSession`'s
`kill-window`, older than this PR and worse — it would destroy a bystander's
window outright — so that site moves too. Resolving also means a Restart on a
session whose window went missing now CREATES it instead of failing, which is
the state the panel reports while leaving the button enabled.

**Stop left `sessions.status = 'running'`.** That column is what the rail and
its hover card read, so a stopped agent showed green and "agent: running" —
while the expanded sidebar, which reads the probe, painted the row RED with a
"no claude running in <worktree>" line. The operator stops an agent on purpose
and one surface says it is fine, another says it crashed. `'stopped'` was
already in the union, so this is a write and a guard, no migration.

**Restart claimed to resume a conversation it had not resumed.** `resumeFlag`
emits nothing for the empty `claude_session_id` that migration 0012 backfilled,
so on an upgraded install Restart silently started a fresh conversation under
the old toast, and left the row permanently unresumable. It now mints and pins
an id the way `openWorkspaceSession` does, and reports `resumed: false` so the
toast says which of the two things happened.

**Neither action waited for claude.** tmux exiting 0 only means the pane was
respawned; the launch line falls through to `exec bash` if claude dies on
startup. Both paths now wait — the shape `openWorkspaceSession` already used —
and return `{confirmed}`, so "Agent relaunched, but claude has not come up yet"
is sayable instead of an unconditional success.

The silent-failure pass found four more places the operator is told nothing:
`goto` was the one awaited call outside a try (a rejected navigation became an
unhandled rejection); "Copy branch name" returned silently when the roster had
no branch, leaving the previous clipboard contents to be pasted into a checkout;
"New session…" on a booting workspace expanded and scrolled and then found no
form; and a failed repo-env fetch rendered as "None set for this repository" —
an affirmative claim produced by not knowing, on the pane whose whole job is
stating a blast radius. `worker-images` also latched a transient failure for the
life of the page, so one bad response hid every capability chip.

Two more the reviewers caught in the merged workspace tab: it offered "Stop
shell agent" beside "Open workspace shell" (distinct actions, so the new
dedupe did not collapse them) — fixed in the builder, since offering to open a
running shell was equally wrong on the card; and a session Settings panel
restored from localStorage rendered the WORKSPACE pane, complete with a live
"Remove workspace…", during the window between listWorkers and listSessions.

Verified: the getter on `deps.menuHost` does survive the `$state` proxy (an
accessor has no `writable`, so the trap falls through to `Reflect.get` and the
getter runs on every read), `lastProbedAt` is per-instance, and `dedupeByAction`
keeps its separator. Five new tests cover the numeric name, exact-name matching,
the missing-window fallback, the unresumable row and the status write; each was
confirmed to go red against the behaviour it describes.
lz referenced this pull request from a commit 2026-09-04 00:15:25 +02:00
lz changed target branch from feat/startup-visibility to main 2026-09-04 17:24:26 +02:00
lz merged commit 855baa6841 into main 2026-09-04 17:24:35 +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!100
No description provided.