Repository navigation
feat(workspace): give terminals the same session features as agents - #872
Merged
Merged
Conversation
…gents Research found that prompt templates and the key vault already reach both kinds of terminal (#840). What is still agent-only is session metadata and chrome, gated by ~40 checks that ask "is this an agent?" when they mean "is this a session": title, spoken name, pin, label jump, header, auto-follow, the status panel and the operator's session capabilities. Shells also have no activity signal at all. This plan lifts those checks, gives plain terminals the shared PaneHeader, adds a main-process foreground monitor for shell activity, and fixes the remote leak (#866) and the terminal-view related-agent gap (#858). Refs #865 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
A title is session metadata the user writes to scan a busy grid, and every reader of SessionMeta.title already handled terminals. Only the command guard, the action and the reducer refused them, a scope decision from #660 that #865 reverses. The command id stays agent.title.set so saved keybindings survive. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Names were withheld from shells because a spoken shell name looked like an unroutable target. Since the operator gained terminals.input that reason is gone, and a shell running a server is exactly what a voice user wants to say out loud. Shells also reserve the name row, because the same late-arrival resize would otherwise SIGWINCH a TUI running in the shell. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…ocess Shells emitted only started/data/exit, so nothing could say a terminal was busy: Status Mode, tab counts and the Dispatch shell subtitle never lit. A single poller asks tmux (one list-panes for every managed session) or node-pty which process owns each terminal's foreground, and emits only on change on a dedicated channel, so the remote tap and the recorder never see terminal state. The poll exists only while terminals do and cannot overlap itself. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…nread Main's foreground observations become the same processActive/activityStatus inputs an agent spinner produces, so Status Mode, tab counts, the close confirmation and Dispatch treat a busy shell as running with no terminal branches downstream. A finished command marks the session unread like a completed turn. withUnread moves to session-runtime so both writers share the attention-outranks-output rule. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…-follow TerminalLeaf hand-drew a 'terminal $' strip that predated every header feature, so shells had no folder, color flag, title/name row or Status Mode fill, the same drift #851 fixed for agent terminal views. It now renders PaneHeader with the foreground command as its badge and the live tmux cwd, carries data-pane-id, and follows output through the shared xterm follow hook, which un-gates Auto-follow and Jump to Latest for shells. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…ncluded A dozen title fallbacks disagreed. The close dialog showed a raw session UUID for any untitled session, and the buried picker showed 'kind · folder'. One sessionDisplayTitle rule now covers them, a terminal's Dispatch row follows the live tmux cwd, and shell rows show NEW, ERROR and the running command like any other row. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Pins excluded terminals at seven layers, a scope decision from before shells were Dispatch rows at all. Since #671 a terminal is a full row, and a pinned dev-server shell is the one-keystroke-away session pins exist for. The invariant shrinks to every pinned id is a live session; command ids stay stable. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Typing a terminal's label fell through to ordinary command search while Dispatch keyboard navigation already selected terminals, so the two ways of reaching a pane disagreed. Label resolution and index navigation now accept every session kind. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
The pane-label command row hardcoded "agent" in its title/description even though buildAgentPaneLabelTarget now returns terminal targets too, so typing a terminal's label showed copy claiming resume/clone semantics no shell has. Branch the copy on target.kind, and cover both kinds plus two more terminal-target navigation paths (Tiled Dispatch lane, grid swap). Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…ists Close Old Agents and the Activity modal had no age for a shell, Agent Status refused one, the Worktrees panel ignored shells running inside a worktree, and the Activity modal hid Bury for terminals although burying them works. Each now treats a terminal as a session, aged by its last foreground change. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
The operator could create, read and write a terminal but not find, show, title, pin, restore or close it. Session capabilities now accept every kind, while agents.prompt refuses a terminal with the route that exists instead (terminals.input), so a spoken shell name can never become a provider prompt. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
requireSession stopped refusing terminals for #865, which made agents.show newly reachable with one while Reader Mode was open. Reader has no transcript view for a terminal, and its own setReaderModeSession action had no kind guard (unlike its toggleReaderMode/setReaderModeTarget siblings) — it was protected only by ReaderView's rendering filter, by accident. Give setReaderModeSession the same agent-kind guard as its siblings, and have agents.show refuse a terminal up front whenever Reader Mode owns the screen, before any focus/navigation call. Spotlight is unaffected; it shows terminals fine. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
A persisted related selection mounted a child's raw TUI under the parent's pane label with nothing marking it, so input could go to the wrong agent. The existing status row now names the displayed agent and offers the way back to the parent, without adding a row that would resize the live TUI. Fixes #858 Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Dispatch's New Agent picker could not create a terminal and its commit path kept a dead branch that ignored the project header. Undo Clear Composer showed on shells. Copy Last Response, View Prompts and Reader appeared on OpenCode Terminal and silently did nothing. The template fill pane claimed replace or append on a terminal that only ever pastes at the cursor. Stale comments about proxy retention and template scope are corrected. Also widens createDetachedDispatchAgent's declared parameter type to the full SessionSpawnSelection (was SessionSpawnSelection & Exclude<SessionKind, 'terminal'>), matching what the underlying implementation already accepted — its createDetachedSession alias never carried the narrower type, and control/terminals.ts has been passing kind: 'terminal' through the same function for a while. Without this the Dispatch terminal call above does not type-check. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
The remote feed filtered terminals only on the started event, so readiness, exit and process-state frames still carried terminal session ids, and submit and interrupt accepted any id. A paired client could press Enter in a shell it was never shown. The source now drops terminal frames at its single emit choke point, and inbound writes require an agent session. Fixes #866 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…gate I1/#866: SessionFeedSource.emit() gated on SessionManager.getSessionKind, which reads only the live registry entry. Every spawn (terminals included) emits input-readiness {ready:false, reason:'starting'} BEFORE that entry is inserted, so a terminal's very first readiness frame slipped past the gate, got broadcast, and stuck forever in RemoteServer.lastInputReadiness (later exit/removed frames for that id ARE filtered once the entry exists, so nothing ever evicts the stale cache row). Add SessionManager.getSpawnKind(), reading the earlier-populated spawnInfo map, and fall back to it in the gate: (getSessionKind ?? getSpawnKind) === 'terminal'. spawnInfo is deleted in cleanupSessionState alongside the registry entry, so the fallback never outlives a session's lifecycle. T13: add a sibling RemoteServer.integration.test.ts case proving permission-reply is refused for a terminal session id ({ ok: false, error: 'not an agent session' }, manager.write/resolveCondition never called) — kept separate from the existing submit/interrupt refusal test because permission-reply exercises a third manager method that test's assertions don't cover, and blurring them would hide which guard a future regression broke. Refs #865, #866 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…s, and fix multi-window pane selection
M1: TmuxRegistry.runTmuxCapture resolved on the child's 'exit' event, but
stdout is a separate pipe that can still have buffered data in flight at
that instant — resolve on 'close' instead, once stdio is fully drained.
Also add an optional timeout (2000ms on listPaneForeground only, since it
runs on the 1s foreground poll for every open terminal and a wedged tmux
server would otherwise freeze that poll forever; listManagedSessions keeps
running with no timeout, since it only runs once at launch and hanging
there is a startup bug worth surfacing rather than racing a clock).
TerminalForegroundMonitor.tick() sampled direct PTYs and tmux panes in one
combined loop AFTER awaiting the tmux listing, so a slow/hung tmux server
delayed every direct-PTY terminal's foreground update too. Direct sessions
are now sampled and emitted unconditionally before the tmux await.
M4: PANE_FOREGROUND_FORMAT read only #{pane_active}, but tmux tracks an
active pane PER WINDOW — a background window's own active pane still
reports pane_active=1. Add #{window_active} and require both flags so a
multi-window session reports the pane actually visible to an attached
client, not whichever window's line happened to sort last.
M7: wrap each onChange call in try/catch (applySample) so one bad
subscriber can't turn `void this.tick()`'s interval driver into an
unhandled promise rejection that takes down foreground polling for every
other tracked terminal.
Refs #865
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…s, and unify transcript/title rules
I3/D3: DispatchAgentList's per-row useShallow selector picked up
activityStatus for every row. Codex's activity string ticks every second
("working… 12s"), and the dispatch list never paints activityStatus for a
non-terminal row (dispatchSubtitle only reads it under kind === 'terminal'),
so every live Codex/Claude row was re-rendering once a second for a value it
never shows. Select it only for terminal rows. DispatchMiniList never
selected activityStatus, so it needed no change.
M3: useIpcSubscriptions' onSessionExit handler cleared processActive/
activityStatus but left terminalForeground untouched. The foreground monitor
(#865) only emits on change, so a dead shell's last command/cwd survived
forever with nothing left to correct it, and a same-id respawn's first
genuinely-idle sample risked being deduped against that ghost "busy" state.
Clear it alongside the other exit-time fields.
M5: reader.ts's setReaderModeSession guard and agents.ts's agents.show
refusal both used isAgentProviderKind/`provider === 'terminal'`, which admits
OpenCode Terminal (agent-provider-kind but providerRuntime 'terminal', so it
never loads a transcript). readerCommands.ts already gates on
sessionHasTranscript for the same reason; both guards now agree with it
instead of diverging.
M6: dispatchSelectors' sessionTitle/basename reimplemented its own
title→folder→cwd fallback instead of using sessionDisplayTitle, the shared
D6 title rule every other workspace-layer reader already follows. Dispatch
rows have no live tmux cwd available (that only lives in zustand runtime
state), so this resolves title → spawn folder → raw cwd with no live-cwd
override, matching the old behavior; DispatchAgentList layers the live cwd
back on top separately via dispatchRowTitle. The local basename helper is
now unused and removed; a local 'agent' fallback stays for the
meta-undefined case sessionDisplayTitle cannot itself handle.
Refs #865
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…/T7) Several comments and user-facing strings still described pre-#865 behavior after terminal-session parity landed: TerminalLeaf.tsx now resolves tailAllMode too (uiShell/types.ts said it never did), a terminal pane now renders the same shared PaneHeader chrome as an agent pane (workspace/ types.ts said "no Agent Code chrome"), and Close Old Agents now batch-closes terminals alongside agents (its dialog copy, empty state, and command description/comment still said "Claude and Codex agents" / "stale provider panes"). The Set Title command was renamed from "Set Agent Title" but a uiShell/types.ts comment and the test file's describe block (T1) still quoted the old name. The Pin Sessions command was renamed from "Pin Agents" but paneCommands.ts comments and PinAgentsModal.tsx's header comment (T7) still quoted the old title, and its "favorite agents" description text didn't yet mention terminals now being pinnable too. None of this changes behavior — command ids are untouched, only titles/ descriptions/comments — so no test asserts on the old strings; the affected suites (agentTitleCommands.test.ts, catalog.test.ts, closeConfirmation.test.ts, CloseOldAgentsModal.rows.renderer.test.ts, closedModalDerivations.renderer.test.tsx) all still pass. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
agents.search's `provider` filter enum was `['claude', 'codex', 'opencode']` — terminals have been searchable/returnable by this capability since #865 (session.provider is genuinely 'terminal' for a shell), but zod rejected `provider: 'terminal'` outright before it ever reached the filter, making "find just my shells" impossible through the operator surface. Add 'terminal' to the enum. agents.titleSet's input/output .describe() text called the field "Agent display title" even though the capability's own description already says it sets an "agent or terminal title" — the schema text contradicted the capability it documents. Reworded to "Session display title" (agents.create keeps its own separate title field/text; it only ever creates agents, never terminals, so it stays as-is). Refs #865, #866 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
…itle Comment and label corrections from the final re-review; no behavior change. Refs #865 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Brings #860 (New Agent In), #861 (Reader Mode from the render ledger) and #870 (Codex cyber-block native clone) into the branch before merge. The textual merge is clean; eight files changed on both sides, so the merged tree is re-verified with tsc and the full suite rather than trusting the pre-merge CI run. Refs #865 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F
Juliusolsson05
added a commit
that referenced
this pull request
Sep 12, 2026
…undary Brings the wheel-boundary fix (#791) onto current main, 379 commits ahead, including the xterm 6.1.0-beta.304 / WebGL re-enable (#873), terminal session parity (#872) and OpenCode Terminal (#882). Conflicts, all at the three xterm hosts: - AgentInlineTerminal.tsx: main moved the WebGL attach below `scheduleFit` (onRendererChange); keep only the boundary attach right after open(). - AgentTerminalLeaf.tsx: imports - keep the boundary import, drop AgentTitleHeader (removed on main in favor of PaneHeader); body - boundary attach after open(), then main's onRendererChange WebGL attach. - TerminalLeaf.tsx: imports - keep both sides; body - same as above. Re-checked against the installed beta: the scrollable element still only consumes a wheel it scrolled (alwaysConsumeMouseWheel and consumeMouseWheelIfScrollbarIsNeeded default false, Viewport sets neither), and MouseService's passive wheel handler returns without preventDefault while the buffer has scrollback, so the boundary leak persists and the bubbling host listener remains the right place to cancel it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Plain terminal panes are first-class sessions structurally: grid leaves, Dispatch rows, command targets, operator
workspace.observeentries, tmux-persistent across restarts. But about 40 checks asked "is this an agent?" (isAgentProviderKind) when they meant "is this a session the user manages". So a shell could not be titled, named, pinned, jumped to by its pane label, auto-followed, inspected in Agent Status, or seen as busy. Its header was a hand-drawnterminal $strip older than every header feature since April (folder, color flag, title/name row, Status Mode). Shells also had no activity signal at all: they only ever emitted started, data and exit.Prompt templates and the Key Vault already reached terminals (#830 / #831 via #840). This PR finishes the job for both kinds of terminal:
SessionKind 'terminal',TerminalLeaf);AgentTerminalLeaf).It also closes a remote-phone leak (#866) and the terminal-view related-agent gap (#858).
What changed
Session features for every session kind (the terminal exclusions are deleted, not generalized):
agent.title.setunchanged) and the operator'sagents.titleSet.A2!now reaches terminals, and the palette copy names a terminal correctly.agents.locate/show/close/restore/list/titleSet/pinSetandagents.search(whose provider filter now acceptsterminal) all accept terminals.agents.promptrefuses a terminal before any wake or write, and points toterminals.input.One header:
TerminalLeafrenders the sharedPaneHeader, likeAgentTerminalLeafsince fix(workspace): light the status header for terminal-view agent panes #853: live folder (followscdthrough tmux), color flag, title/name row, Status Mode fill, TAIL pill, and the foreground command as the badge.data-pane-idand auto-follow / Jump to Latest / Auto-follow All through the shareduseTerminalFollowhook (renamed fromuseAgentTerminalFollow).Shell activity:
TerminalForegroundMonitorreads each terminal's foreground command. tmux-backed terminals use onetmux list-panes -aper second for all managed sessions (active pane of the active window); direct PTYs use node-pty'sprocess.session:terminal-foregroundchannel.processActive/activityStatusinputs. Status Mode, tab running counts, the "still working" close confirmation and the Dispatch subtitle (shell running · npm) therefore light for shells with no terminal-specific code downstream.Naming: one
sessionDisplayTitlerule. An untitled session is named by its live folder, then its spawn folder, never by its raw session UUID (which the close dialog used to show). A terminal's Dispatch row followscd. Shell rows show NEW and ERROR.Terminal view (#858): the one-line status row names the related agent being displayed (
raw codex · orchestration worker-2), and aparentbutton returns to the owner. No chip row is added, so the TUI is never resized by this.Small fixes found during research:
sessionHasTranscriptpredicate.Remote phone (#866):
SessionFeedSourcedrops any frame whose session is a terminal at its singleemitchoke point. The check also consults the spawn-time kind, so the pre-registrationstartingreadiness frame is covered.RemoteServerrefusessubmit,interruptandpermission-replyfor non-agent sessions.Design decisions and tradeoffs
terminals.inputin feat(control): expose external MCP through an isolated capability SDK #793, so a named shell is routable.deliverPromptToAgentis untouched), transcript features, composer features, Reader Mode (now guarded at the action level too), and the remote phone.process-state, because the remote tap and the session recorder subscribe toprocess-stateand must never see terminal state. The poll has an in-flight guard, a 2 s tmux timeout, and runs only while terminals exist.vimkeeps its pane lit, which is literally true and makes the close confirmation useful. Commands shorter than one poll interval are never seen as busy.TerminalLeafkeeps its no-re-render-per-chunk contract: only primitive store selectors. Dispatch rows selectactivityStatusonly for terminal rows, so Codex's per-secondworking… 12stick does not re-render agent rows.Tests
Tests sit next to the modules they protect. Tests that asserted the old exclusions were rewritten, each with the reason. Added or extended:
terminalForeground.test.ts: classifier, track/untrack lifecycle, in-flight guard, tmux failure keeps state, direct PTYs not blocked by a pending listing.TmuxRegistry.test.ts: listing parser, prefix filter, active pane of the active window, paths containing tabs.session-runtime/terminalForeground.test.tsanduseTerminalForeground.renderer.test.tsx: running/NEW semantics, snapshot waits for restore.TerminalLeaf.header.renderer.test.tsx: shared header,data-pane-id, command badge, Status Mode, live cwd, color flag, title row, TAIL.AgentTerminalLeaf.related.renderer.test.tsx: bug(workspace): terminal view hides which related agent a pane is showing #858.sessionDisplayTitle.test.ts,rowTitle.test.ts,closeConfirmation.test.ts.dispatchSelectors.test.ts,paneLabels.test.ts,agentIndexNavigation.test.ts(grid, tiled-Dispatch lane, detached swap),agentIndexCommand.test.ts.CloseOldAgentsModal.rows.renderer.test.ts,agentStatusModel.renderer.test.ts,loadWorktreeDump.test.ts.agents.renderer.test.ts: terminal metadata and navigation accepted, prompt refused, show refused while Reader owns the screen.transcriptAvailability.test.ts,PromptTemplateFillPane.renderer.test.tsx,NewAgentPlacementOverlay.renderer.test.tsx.SessionFeedSource.test.ts(including the pre-registration window) andRemoteServer.integration.test.ts(submit, interrupt and permission-reply refused for terminals).useIpcSubscriptions.renderer.test.tsx:terminalForegroundcleared on exit.There is no SessionManager-level wiring test: it would need a real node-pty spawn, which this suite does not do. The monitor, classifier and parser carry the behavior.
Verification
Run on Node 24.14.1 at the final head:
npm run test:contract: passnpm run check:keybindings: passnpm run typecheck: passnpm test: 3113 passed, 2 failed. Both are known local-only environment failures, in areas this branch does not touch:imageAttachment.test.tscites a Claude session file that was deleted from this machine;store.test.ts's prompt-template migration test times out at 5 s under full-suite load and passes 8/8 on its own.CI's quality-gate is the authoritative run.
Process: each of the 13 implementation tasks had an independent spec and quality review; Tasks 8 and 10 each needed one fix round. An Opus whole-branch review found three important and nine minor findings. One fix wave addressed them, and a scoped Opus re-review confirmed every item.
Known limitations and follow-ups
opencode-headless, because its foreground process is alwaysopencode.reader.tssetReaderModeTarget/toggleReaderModestill checkisAgentProviderKindrather thansessionHasTranscript, so OpenCode Terminal can still open an empty Reader by keybind. This predates the branch.startingreadiness frame in the remote late-joiner cache. This predates the branch; it is the same class as bug(remote): remote clients can act on terminal sessions the phone is never shown #866 but for agents.Needs a live check (the app was not launched during this work)
npm testruns in a tmux terminal and clears when it finishes. The Dispatch row showsshell running · nodeand then NEW.Fixes #865
Fixes #866
Fixes #858
Refs #857 #851 #853 #830 #831 #816 #660 #152 #793
🤖 Generated with Claude Code
https://claude.ai/code/session_01LEwGeHkkhnDxU3X3chXX3F