From b87849345f922e663eec0c870fd462873842acd6 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 4 Oct 2026 15:46:15 -0400 Subject: [PATCH 1/3] refactor(ui): move routed surfaces and agent-workspace state onto the view scope (RIG-4442) Co-authored-by: Matt Wilkinson --- apps/ui/src/App.tsx | 5 +- apps/ui/src/agent-tabs.ts | 126 ++++ apps/ui/src/comms.ts | 10 + .../ui/src/components/AgentView.live.test.tsx | 5 +- apps/ui/src/components/AgentView.test.tsx | 36 +- apps/ui/src/components/AgentView.tsx | 40 +- .../src/components/ChannelView.ask.test.tsx | 5 +- .../components/ChannelView.composer.test.tsx | 5 +- .../components/ChannelView.policy.test.tsx | 5 +- apps/ui/src/components/ChannelView.test.tsx | 9 +- apps/ui/src/components/ChannelView.tsx | 17 +- apps/ui/src/components/LogPanel.test.tsx | 17 +- apps/ui/src/components/LogPanel.tsx | 12 +- apps/ui/src/components/TopicView.tsx | 11 +- apps/ui/src/components/ViewHost.test.tsx | 82 +++ apps/ui/src/components/ViewHost.tsx | 47 ++ apps/ui/src/identity.test.ts | 2 +- apps/ui/src/store.live.test.ts | 4 +- apps/ui/src/store.test.ts | 306 +++++---- apps/ui/src/store.ts | 609 ++++-------------- apps/ui/src/stub-data.ts | 2 +- apps/ui/src/view-scope.ts | 221 ++++++- 22 files changed, 886 insertions(+), 690 deletions(-) create mode 100644 apps/ui/src/agent-tabs.ts create mode 100644 apps/ui/src/components/ViewHost.test.tsx create mode 100644 apps/ui/src/components/ViewHost.tsx diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index be3522b15..ce8ca45e2 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -23,6 +23,7 @@ import { ShortcutsOverlay } from "./components/ShortcutsOverlay"; import { StateDot } from "./components/StateDot"; import { TopBarSearch } from "./components/TopBarSearch"; import { UsageBar } from "./components/UsageBar"; +import { ViewHost } from "./components/ViewHost"; import { useStore } from "./context"; import type { CommandId } from "./keyboard/commands"; import { detectPlatform, installKeymap } from "./keyboard/dispatch"; @@ -169,7 +170,9 @@ const App: Component< -
{props.children}
+
+ +
diff --git a/apps/ui/src/agent-tabs.ts b/apps/ui/src/agent-tabs.ts new file mode 100644 index 000000000..c16a05597 --- /dev/null +++ b/apps/ui/src/agent-tabs.ts @@ -0,0 +1,126 @@ +// The agent workspace's tab and split-pane model. Pure, so the store and each +// view scope share it without importing each other. + +/** What a single pane in the agent view shows: the chat conversation, a + * terminal, or a file. A pane is the actual UI leaf — the thing rendered on + * screen. */ +export type PaneKind = "chat" | "terminal" | "file"; + +/** One pane in the agent view (design D6/T7): a leaf UI. `terminalId`/`filePath` + * are set for the matching kind; the chat pane carries neither. */ +export interface Pane { + id: string; + kind: PaneKind; + title: string; + terminalId?: string; + filePath?: string; +} + +/** A binary split tree of panes within one tab (T7): a leaf shows one pane; a + * split places two children row (side by side) or column (stacked), nesting + * recursively. "Split right" adds a row split, "split down" a column split. */ +export type SplitNode = + | { kind: "leaf"; pane: Pane } + | { + kind: "split"; + direction: "row" | "column"; + left: SplitNode; + right: SplitNode; + }; + +/** A tab in the agent view: a group of panes shown together on one screen. + * Tabs are the top-level switcher (clicking a tab shows its panes full-screen); + * a tab owns its own split tree and remembers which pane is focused (the pane + * the split buttons act on). The first tab is always the chat (design D6). */ +export interface AgentTab { + id: string; + title: string; + /** The split tree of panes in this tab. */ + layout: SplitNode; + /** The focused pane id — where "split right"/"split down" insert. */ + focusedPaneId: string; +} + +/** The always-present chat tab/pane id — the home-DM conversation, which every + * agent view opens with and can never close (design D6). The tab and its sole + * starting pane share this id. */ +export const CHAT_TAB_ID = "chat"; + +/** The chat pane — the home-DM conversation leaf every agent view opens on. */ +const chatPane = (): Pane => ({ + id: CHAT_TAB_ID, + kind: "chat", + title: "Chat", +}); + +/** The default tab set: one chat tab holding the chat pane full-screen. */ +export const chatTab = (): AgentTab => ({ + id: CHAT_TAB_ID, + title: "Chat", + layout: { kind: "leaf", pane: chatPane() }, + focusedPaneId: CHAT_TAB_ID, +}); + +/** Every pane id in a tab's split tree, left-to-right (the pane layout order). */ +export function splitPaneIds(node: SplitNode): string[] { + return node.kind === "leaf" + ? [node.pane.id] + : [...splitPaneIds(node.left), ...splitPaneIds(node.right)]; +} + +/** Every pane in a tab's split tree, left-to-right. */ +export function splitPanes(node: SplitNode): Pane[] { + return node.kind === "leaf" + ? [node.pane] + : [...splitPanes(node.left), ...splitPanes(node.right)]; +} + +/** Remove a pane from a tab's split tree, collapsing any split that loses a + * child to its surviving sibling. Returns null if the tree would be empty. */ +export function prunePane(node: SplitNode, paneId: string): SplitNode | null { + if (node.kind === "leaf") return node.pane.id === paneId ? null : node; + const left = prunePane(node.left, paneId); + const right = prunePane(node.right, paneId); + if (left && right) return { ...node, left, right }; + return left ?? right; +} + +/** Split the FIRST leaf matching `targetPaneId`, placing `newPane` beside it in + * `direction` (`row` = split right, `column` = split down). Recurses left-first + * and stops at the first match, so splitting grows the tree by exactly one pane + * (no pane explosion when a pane id somehow repeats). Returns the rewritten + * tree and whether a leaf matched. */ +export function splitPaneOnce( + node: SplitNode, + targetPaneId: string, + newPane: Pane, + direction: "row" | "column", +): [SplitNode, boolean] { + if (node.kind === "leaf") { + return node.pane.id === targetPaneId + ? [ + { + kind: "split", + direction, + left: node, + right: { kind: "leaf", pane: newPane }, + }, + true, + ] + : [node, false]; + } + const [left, insertedLeft] = splitPaneOnce( + node.left, + targetPaneId, + newPane, + direction, + ); + if (insertedLeft) return [{ ...node, left }, true]; + const [right, insertedRight] = splitPaneOnce( + node.right, + targetPaneId, + newPane, + direction, + ); + return [{ ...node, right }, insertedRight]; +} diff --git a/apps/ui/src/comms.ts b/apps/ui/src/comms.ts index 8692df0e5..512f0599b 100644 --- a/apps/ui/src/comms.ts +++ b/apps/ui/src/comms.ts @@ -51,6 +51,16 @@ export function browsableChannels(channels: readonly Channel[]): Channel[] { return channels.filter((c) => c.membership === "none"); } +/** The boot channel: the first subscribed one, else the first visible, else + * null before the first snapshot (components render an empty state). */ +export function firstChannelId(channels: readonly Channel[]): string | null { + return ( + channels.find((c) => c.membership === "subscribed")?.id ?? + channels[0]?.id ?? + null + ); +} + // ── Channel post policy + pinned board (comms substrate §A2/§A3) ───────────── /** Whether `callerId` may post to `channel` under its post policy (comms diff --git a/apps/ui/src/components/AgentView.live.test.tsx b/apps/ui/src/components/AgentView.live.test.tsx index e65ca04c5..a0ef96a61 100644 --- a/apps/ui/src/components/AgentView.live.test.tsx +++ b/apps/ui/src/components/AgentView.live.test.tsx @@ -14,6 +14,7 @@ import { StoreContext } from "../context"; import { createFakeComms, type FakeComms } from "../live/comms-fake"; import { type AppStore, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { AgentView } from "./AgentView"; // The LIVE AgentView header (T4): a live agent has no fixture `model`/`cwd` @@ -76,7 +77,9 @@ async function mountLive(fake: FakeComms): Promise<{ }); return ( - + + + ); }); diff --git a/apps/ui/src/components/AgentView.test.tsx b/apps/ui/src/components/AgentView.test.tsx index ce6c1f75e..3c749d936 100644 --- a/apps/ui/src/components/AgentView.test.tsx +++ b/apps/ui/src/components/AgentView.test.tsx @@ -1,16 +1,13 @@ import { describe, expect, test } from "bun:test"; import { fireEvent, render } from "@solidjs/testing-library"; import { flush } from "solid-js"; +import { CHAT_TAB_ID, type Pane } from "../agent-tabs"; import { STUB_COMMS_STATE } from "../comms-stub"; import { StoreContext } from "../context"; -import { - type AppStore, - CHAT_TAB_ID, - createAppStore, - type Pane, -} from "../store"; +import { type AppStore, createAppStore } from "../store"; import { STUB_AGENTS } from "../stub-data"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { AgentView, nextFreeTerminalPane } from "./AgentView"; // RED acceptance spec for T3 (design.md §504-545): the rebuilt AgentView — a @@ -34,7 +31,7 @@ const AGENT_ID = "acc-compass-ui"; // topic index must show. A DM is topics too (Matt's ruling): one home topic. const HOME_DM_TOPIC = "general"; -// compass-ui's two terminals, as the terminal panes `openTab`/`splitActivePane` place. +// compass-ui's two terminals, as the terminal panes `openTab`/`splitFocused` place. // `t-ui1`'s scrollback carries this line — proof the right terminal rendered. const TERM_C1_LINE = "VITE v8.1.0 ready in 247 ms"; const termPaneC1: Pane = { @@ -67,7 +64,9 @@ function mountAgentView(): { }); return ( - + + + ); }); @@ -98,7 +97,7 @@ describe("AgentView (T3)", () => { // The chat tab carries no close button (tab.id === CHAT_TAB_ID guard). expect(tabs[0]?.querySelector(".av-tab-close")).toBeNull(); - store.closeTab(CHAT_TAB_ID); + store.focusedView().closeTab(CHAT_TAB_ID); flush(); // Still exactly one tab — the chat tab can't be closed. @@ -157,7 +156,7 @@ describe("AgentView (T3)", () => { const { store, container } = mountAgentView(); store.openAgent(AGENT_ID); flush(); - store.openTab(termPaneC1); + store.focusedView().openTab(termPaneC1); flush(); // Chat tab + the new terminal tab. @@ -177,9 +176,9 @@ describe("AgentView (T3)", () => { const { store, container } = mountAgentView(); store.openAgent(AGENT_ID); flush(); - store.openTab(termPaneC1); + store.focusedView().openTab(termPaneC1); flush(); - store.splitActivePane(termPaneC2, "row"); + store.focusedView().splitFocused(termPaneC2, "row"); flush(); const split = container.querySelector(".av-tree .av-split.row"); @@ -269,15 +268,17 @@ describe("AgentView always-open '+'/split (regression)", () => { flush(); // Exhaust both fixture terminals through the real store action. - store.openTab(termPaneC1); + store.focusedView().openTab(termPaneC1); flush(); - store.openTab(termPaneC2); + store.focusedView().openTab(termPaneC2); flush(); expect(container.querySelectorAll(".av-tab").length).toBe(3); // No unplaced fixture terminal remains — the fallback path is now live. const selected = store.selectedAgent(); if (!selected) throw new Error("compass-ui not selected"); - expect(nextFreeTerminalPane(selected, store.agentTabs())).toBe(undefined); + expect( + nextFreeTerminalPane(selected, store.focusedView().agentTabs()), + ).toBe(undefined); const newBtn = (): HTMLButtonElement => { const btn = container.querySelector(".av-tab-new"); @@ -297,7 +298,10 @@ describe("AgentView always-open '+'/split (regression)", () => { // The two minted tabs carry distinct ids (the store's monotonic counter), // distinct from the fixture tabs — no collision, no dedupe-swallow. - const ids = store.agentTabs().map((t) => t.id); + const ids = store + .focusedView() + .agentTabs() + .map((t) => t.id); expect(new Set(ids).size).toBe(ids.length); const minted = ids.filter((id) => id.startsWith("term-acc-compass-ui-")); expect(minted.length).toBe(2); diff --git a/apps/ui/src/components/AgentView.tsx b/apps/ui/src/components/AgentView.tsx index 31f0f81e1..d8c9f94cc 100644 --- a/apps/ui/src/components/AgentView.tsx +++ b/apps/ui/src/components/AgentView.tsx @@ -1,13 +1,14 @@ import { type Component, For, Match, Show, Switch } from "solid-js"; -import { useStore } from "../context"; import { type AgentTab, CHAT_TAB_ID, type Pane, type SplitNode, splitPanes, -} from "../store"; +} from "../agent-tabs"; +import { useStore } from "../context"; import type { Agent, Terminal } from "../stub-data"; +import { useView } from "../view-scope"; import { ChannelView } from "./ChannelView"; import { Glyph } from "./Glyph"; import { LogPanel } from "./LogPanel"; @@ -54,18 +55,19 @@ const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }> = ( props, ) => { const store = useStore(); + const view = useView(); // The terminal pane the split buttons open: the agent's next unplaced FIXTURE // terminal (rich scrollback) while any remain, else a freshly-minted // placeholder from the store. Always returns a pane, so the split buttons are // always enabled — fixture terminals first, then placeholders. const nextOrMintedTerminalPane = (): Pane => - nextFreeTerminalPane(props.agent, store.agentTabs()) ?? + nextFreeTerminalPane(props.agent, view.agentTabs()) ?? store.newTerminalPane(props.agent); // Split this tab's focused pane, opening that terminal beside it (row = split // right, column = split down); a later context menu will pick the pane kind. const splitWith = (direction: "row" | "column") => { - store.setFocusedPane(props.pane.id); - store.splitActivePane(nextOrMintedTerminalPane(), direction); + view.setFocusedPane(props.pane.id); + view.splitFocused(nextOrMintedTerminalPane(), direction); }; return (
@@ -75,7 +77,7 @@ const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }> = ( class="av-pane-title" title={`Focus ${props.pane.title}`} aria-pressed={props.focused ? "true" : "false"} - onClick={() => store.setFocusedPane(props.pane.id)} + onClick={() => view.setFocusedPane(props.pane.id)} > {props.pane.title} @@ -104,7 +106,7 @@ const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }> = ( class="av-pane-btn av-pane-close" title="Close pane" aria-label="Close pane" - onClick={() => store.closePane(props.pane.id)} + onClick={() => view.closePane(props.pane.id)} > @@ -113,7 +115,7 @@ const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }> = (
- + { const store = useStore(); + const view = useView(); // Open a new full-screen terminal tab: reuse the next unplaced fixture // terminal (rich scrollback) while any remain, else mint a fresh placeholder. // Always opens a tab — the "+" button never disables. const openTerminalTab = (agent: Agent) => { - store.openTab( - nextFreeTerminalPane(agent, store.agentTabs()) ?? + view.openTab( + nextFreeTerminalPane(agent, view.agentTabs()) ?? store.newTerminalPane(agent), ); }; return ( - Select an agent.

} - > + Select an agent.

}> {(agent) => (
@@ -239,12 +239,12 @@ export const AgentView: Component = () => {
- + {(tab) => (
@@ -264,7 +264,7 @@ export const AgentView: Component = () => { class="av-tab-close" aria-label={`Close ${tab.title}`} title={`Close ${tab.title}`} - onClick={() => store.closeTab(tab.id)} + onClick={() => view.closeTab(tab.id)} > @@ -284,7 +284,7 @@ export const AgentView: Component = () => {
No tab open.
} > {(tab) => ( diff --git a/apps/ui/src/components/ChannelView.ask.test.tsx b/apps/ui/src/components/ChannelView.ask.test.tsx index 966beae48..4ac31bd88 100644 --- a/apps/ui/src/components/ChannelView.ask.test.tsx +++ b/apps/ui/src/components/ChannelView.ask.test.tsx @@ -11,6 +11,7 @@ import { } from "../live/comms-fake"; import { type AppStore, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { TopicView } from "./TopicView"; // The MULTI-question ask surface, over the live wire. The single-question ask @@ -70,7 +71,9 @@ async function mountAsk(fake: FakeComms): Promise<{ }); return ( - + + + ); }); diff --git a/apps/ui/src/components/ChannelView.composer.test.tsx b/apps/ui/src/components/ChannelView.composer.test.tsx index 53e510da8..5d1013756 100644 --- a/apps/ui/src/components/ChannelView.composer.test.tsx +++ b/apps/ui/src/components/ChannelView.composer.test.tsx @@ -12,6 +12,7 @@ import { } from "../live/comms-fake"; import { type AppStore, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { TopicView } from "./TopicView"; // The topic composer's POSTING contract. In the two-level model a message is @@ -93,7 +94,9 @@ async function mountComposer(fake: FakeComms): Promise<{ }); return ( - + + + ); }); diff --git a/apps/ui/src/components/ChannelView.policy.test.tsx b/apps/ui/src/components/ChannelView.policy.test.tsx index d0c8e387a..609e2fb21 100644 --- a/apps/ui/src/components/ChannelView.policy.test.tsx +++ b/apps/ui/src/components/ChannelView.policy.test.tsx @@ -14,6 +14,7 @@ import { } from "../live/comms-fake"; import { type AppStore, CALLER_ID, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { ChannelView } from "./ChannelView"; import { TopicView } from "./TopicView"; @@ -82,7 +83,9 @@ async function mountComposer(fake: FakeComms): Promise<{ }); return ( - + + + ); }); diff --git a/apps/ui/src/components/ChannelView.test.tsx b/apps/ui/src/components/ChannelView.test.tsx index e64ef4c21..c6675b3ba 100644 --- a/apps/ui/src/components/ChannelView.test.tsx +++ b/apps/ui/src/components/ChannelView.test.tsx @@ -12,6 +12,7 @@ import { import { StoreContext } from "../context"; import { type AppStore, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { ChannelView } from "./ChannelView"; import { TopicView } from "./TopicView"; @@ -112,7 +113,9 @@ function mountTopicView(): { }); return ( - + + + ); }); @@ -362,7 +365,9 @@ describe("ChannelView is a composerless topic index (T5 model boundary)", () => }); return ( - + + + ); }); diff --git a/apps/ui/src/components/ChannelView.tsx b/apps/ui/src/components/ChannelView.tsx index 935252049..aff59b92a 100644 --- a/apps/ui/src/components/ChannelView.tsx +++ b/apps/ui/src/components/ChannelView.tsx @@ -23,6 +23,7 @@ import { } from "../comms-stub"; import { avatarInitial } from "../constants"; import { useStore } from "../context"; +import { useView } from "../view-scope"; import { MarkdownText } from "./MarkdownText"; /** UTC HH:MM for a message timestamp — deterministic, locale-independent (the @@ -582,21 +583,21 @@ const TopicIndex: Component<{ * * The channel source is either an explicit `channel` prop (the agent * workspace passes its bound home DM, so the pane can never drift onto the - * standalone surface's selection) or, when the prop is absent, the global - * `selectedChannel` (the standalone channel mount). The presence check is on - * the prop KEY, not its value: an explicit `undefined` channel is the - * workspace's empty state, NOT a fall-through to `selectedChannel` — that + * standalone surface's selection) or, when the prop is absent, the hosting + * view's routed channel (the standalone channel mount). The presence check is + * on the prop KEY, not its value: an explicit `undefined` channel is the + * workspace's empty state, NOT a fall-through to the view's channel — that * fall-through is what would let a standalone channel bleed into the * interactive workspace pane (D3). */ export const ChannelView: Component<{ channel?: Channel | undefined; }> = (props) => { const store = useStore(); - // Bound to the prop when the caller passes one (workspace), else the global - // selection (standalone). `"channel" in props` keeps an explicit undefined - // from falling through to selectedChannel(). + // A bound pane (workspace, right sidebar) may mount outside any view, so only + // the standalone mount reads the view. + const view = "channel" in props ? undefined : useView(); const channel = (): Channel | undefined => - "channel" in props ? props.channel : store.selectedChannel(); + view ? view.channel() : props.channel; const byId = () => new Map(store.accounts().map((a) => [a.id, a])); return ( diff --git a/apps/ui/src/components/LogPanel.test.tsx b/apps/ui/src/components/LogPanel.test.tsx index 0a2170d81..db72ea084 100644 --- a/apps/ui/src/components/LogPanel.test.tsx +++ b/apps/ui/src/components/LogPanel.test.tsx @@ -8,18 +8,19 @@ import { type AgentSession, foldSession } from "../session-events"; import { STUB_SESSION_EVENTS } from "../session-events-stub"; import { type AppStore, createAppStore } from "../store"; import { testQueryClient } from "../test-support"; +import { ViewContext } from "../view-scope"; import { LogPanel } from "./LogPanel"; // Acceptance spec for T-U2 (design.md §440-478): LogPanel's `TracePane` is -// rebuilt over `foldSession(store.agentSession().events)` and renders the typed +// rebuilt over `foldSession(view.agentSession().events)` and renders the typed // `SessionTrace`; `FrameRow`/`FRAME_TAG` are deleted. The panel SHELL is // UNCHANGED — header handle, running dot, Stop (disabled when idle), minimize // toggle, trace body removed when minimized — so the shell + no-input-box tests // stay GREEN. The two typed-trace tests are RED until SessionTrace.tsx lands and -// store.agentSession() is re-pointed to the new AgentSession (`.events`) shape. +// the view's agentSession() is re-pointed to the new AgentSession (`.events`) shape. // -// Session subtlety (store.ts:602-604): `store.agentSession()` keys off -// `store.selectedAgentId()`, NOT off the `agent` prop. So each test must +// Session subtlety (store.ts:602-604): the view's `agentSession()` keys off +// its route's agent, NOT off the `agent` prop. So each test must // `store.openAgent(id)` (sets selectedAgentId AND resolves selectedAgent) and // pass the resolved agent as the prop — both are driven below. // @@ -73,9 +74,11 @@ function mountLogPanel( store.openAgent(agentId); return ( - - {(agent) => } - + + + {(agent) => } + + ); }); diff --git a/apps/ui/src/components/LogPanel.tsx b/apps/ui/src/components/LogPanel.tsx index 74013f587..2af47d23e 100644 --- a/apps/ui/src/components/LogPanel.tsx +++ b/apps/ui/src/components/LogPanel.tsx @@ -2,16 +2,17 @@ import { type Component, Show } from "solid-js"; import { useStore } from "../context"; import { foldSession } from "../session-events"; import type { Agent } from "../stub-data"; +import { useView } from "../view-scope"; import { Glyph } from "./Glyph"; import { SessionTrace } from "./SessionTrace"; -/** The trace observation pane: the selected agent's typed execution trace. The +/** The trace observation pane: the view agent's typed execution trace. The * raw SessionEvent stream is folded (foldSession) into render-ready TraceItems * and handed to SessionTrace. Observation-only — no composer. */ const TracePane: Component = () => { - const store = useStore(); + const view = useView(); const items = () => { - const s = store.agentSession(); + const s = view.agentSession(); return s ? foldSession(s.events) : undefined; }; return ( @@ -45,13 +46,14 @@ const TracePane: Component = () => { * the running dot stays visible for liveness at a glance. */ export const LogPanel: Component<{ agent: Agent }> = (props) => { const store = useStore(); - const running = () => store.agentSession()?.running ?? false; + const view = useView(); + const running = () => view.agentSession()?.running ?? false; // A fixture-sourced session's id was never minted by a server, so Stop has // nothing it can honestly issue (store.ts `stopAgent` refuses it outright). // Render the control disabled and say why, the same shape the channel rail // uses for its not-yet-wired subscribe control (LeftSidebar.tsx:171-184) — a // visibly-dead button beats one that only reports into the console. - const fixture = () => store.agentSession()?.fixture === true; + const fixture = () => view.agentSession()?.fixture === true; return (