feat(ui): declutter the sidebar onto a shared target menu #100
No reviewers
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus!100
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/target-menu"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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-actionsrow, 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 agentwas ambiguous because it sat on the workspace header next toStart/Stop(the container). It's nowStart/Stop shell agenton the row it controls, and container power isStart/Stop workspacein the workspace menu.Ports and Settings became dock panels.
DockViewgains 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_PALETTEhues 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-row0.10,--wash-tile0.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-visibletoo; 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()wasshowDock()'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 test110 files / 1049 tests ·pnpm run typecheck4864 files, 0 errors ·pnpm run lintclean.Browser-driven against a seeded instance, since the suite is node-only and proves nothing about rendering:
<body>, clamped in viewport, live port count in the hint— set on lz/agent-nexusDeliberately not included
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
onPickswitches, each knowing a different subset ofMenuAction.menu-actions.tsis now the only dispatcher, and the two-tabvariants are composed from the sidebar's own item builders, so the criterion holds by construction.target-menu.test.tspins the superset relation across all four toggled-row combinations — verified by rebuildingsession-tabby 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/DELETEonsessions/:id/agentnow restart and stop claude without touching the row or the worktree. Stop respawns the tmux window as bash rather than killing it, becauseconvergeSessionsrestores 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.WorkerDTOhad noworker_image. One missing field blocked three surfaces; the rail's hover card row labelled "image" printedcontainer_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/quotastopped being fetched once per workspace card.SessionRowandProvisionProgresscome 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,convergeSessionscorrectly leaves it alone, and restart reuses the same window with--resumeon 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 onmainbecause 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:setSessionAgentdropped-e NEXUS_SESSION_ID— found independently by two agents. tmux does not remember a window's original-e, so a restarted session lostnotify-preview/notify-artifactpermanently (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 named1SIGKILLs a different session's claude and leaves it dead whereconvergeSessionswill never look. Same hole indeleteSession'skill-window, older and worse. Both now resolve a window ID.sessions.status = 'running', so the rail showed green while the expanded sidebar showed a red error for the same deliberately-stopped agent.resumeFlagemits nothing for the empty id migration 0012 backfilled, so an upgraded install silently got a fresh conversation.{confirmed}so the toast can say "not up yet".workspace-tabmenu offeredopen-shelltwice, and my guard test compared labels — so did my manual browser check, because I wrote both.gotooutside 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.listWorkersandlistSessions.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.
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.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.9fe5354e8fc07700ad40Two 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.