@@ -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) => (
-
+
{(tab) => (
{
role="tab"
class="av-tab-label"
aria-selected={
- tab.id === store.activeAgentTabId() ? "true" : "false"
+ tab.id === view.activeAgentTabId() ? "true" : "false"
}
- onClick={() => store.setActiveAgentTab(tab.id)}
+ onClick={() => view.setActiveAgentTab(tab.id)}
>
{tab.title}
@@ -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..033969d29 100644
--- a/apps/ui/src/components/LogPanel.test.tsx
+++ b/apps/ui/src/components/LogPanel.test.tsx
@@ -8,20 +8,11 @@ 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
-// `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.
-//
-// Session subtlety (store.ts:602-604): `store.agentSession()` keys off
-// `store.selectedAgentId()`, 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.
+// The trace reads the view's `agentSession()`, keyed on the route's agent, not
+// the `agent` prop, so each test calls `store.openAgent(id)` first.
//
// Fixture ground truth (session-events-stub.ts STUB_SESSION_EVENTS):
// - acc-compass-server: running:true — a thinking beat, ~10 one-word
@@ -73,9 +64,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 (
{
const store = useStore();
- const topic = () => store.selectedTopic();
- const channel = () => store.selectedChannel();
+ const view = useView();
+ const topic = () => view.topic();
+ const channel = () => view.channel();
const byId = () => new Map(store.accounts().map((a) => [a.id, a]));
const byHandle = () =>
new Map(store.accounts().map((a) => [a.handle.toLowerCase(), a]));
diff --git a/apps/ui/src/components/ViewHost.test.tsx b/apps/ui/src/components/ViewHost.test.tsx
new file mode 100644
index 000000000..eba06c6f2
--- /dev/null
+++ b/apps/ui/src/components/ViewHost.test.tsx
@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test";
+import { render } from "@solidjs/testing-library";
+import { flush } from "solid-js";
+import type { Pane } from "../agent-tabs";
+import { STUB_COMMS_STATE } from "../comms-stub";
+import { StoreContext } from "../context";
+import { createAppStore } from "../store";
+import { testQueryClient } from "../test-support";
+import { createViewScope, type ViewScope } from "../view-scope";
+import { ViewHost } from "./ViewHost";
+
+const AGENT_A = "acc-compass-ui";
+const AGENT_B = "acc-compass-server";
+const termPane: Pane = {
+ id: "t-ui1",
+ kind: "terminal",
+ title: "vite dev",
+ terminalId: "t-ui1",
+};
+
+// Two views in one store: each must own its agent and its agent-workspace tabs.
+function mountTwoViews(): {
+ a: ViewScope;
+ b: ViewScope;
+ paneA: HTMLElement;
+ paneB: HTMLElement;
+} {
+ let a!: ViewScope;
+ let b!: ViewScope;
+ const { container } = render(() => {
+ const store = createAppStore({
+ initialComms: STUB_COMMS_STATE,
+ queryClient: testQueryClient(),
+ });
+ a = createViewScope(store, "view-a", `/agent/${AGENT_A}`);
+ b = createViewScope(store, "view-b", `/agent/${AGENT_B}`);
+ return (
+
+
+
+
+ );
+ });
+ flush();
+ const pane = (id: string): HTMLElement => {
+ const el = container.querySelector(`[data-view="${id}"]`);
+ if (!el) throw new Error(`view ${id} not rendered`);
+ return el;
+ };
+ return { a, b, paneA: pane("a"), paneB: pane("b") };
+}
+
+const tabCount = (pane: HTMLElement): number =>
+ pane.querySelectorAll(".av-tab").length;
+
+describe("ViewHost: per-view agent workspace", () => {
+ test("each view renders its own agent", () => {
+ const { paneA, paneB } = mountTwoViews();
+
+ expect(paneA.querySelector(".av-name")?.textContent).toBe("compass-ui");
+ expect(paneB.querySelector(".av-name")?.textContent).toBe("compass-server");
+ });
+
+ test("opening a terminal tab in one view leaves the other unchanged", () => {
+ const { a, b, paneA, paneB } = mountTwoViews();
+ expect(tabCount(paneA)).toBe(1);
+ expect(tabCount(paneB)).toBe(1);
+
+ a.openTab(termPane);
+ flush();
+
+ expect(a.agentTabs().map((t) => t.id)).toEqual(["chat", "t-ui1"]);
+ expect(b.agentTabs().map((t) => t.id)).toEqual(["chat"]);
+ expect(tabCount(paneA)).toBe(2);
+ expect(tabCount(paneB)).toBe(1);
+ expect(paneB.querySelector(".term-body")).toBeNull();
+ });
+});
diff --git a/apps/ui/src/components/ViewHost.tsx b/apps/ui/src/components/ViewHost.tsx
new file mode 100644
index 000000000..d51707086
--- /dev/null
+++ b/apps/ui/src/components/ViewHost.tsx
@@ -0,0 +1,47 @@
+import { Dynamic } from "@solidjs/web";
+import { type Component, createMemo } from "solid-js";
+import { appRoutes } from "../routes";
+import { parseRoute, type RouteMatch, routePath } from "../view-route";
+import { ViewContext, type ViewScope } from "../view-scope";
+
+// Each parsed view renders through its route-table entry, so appRoutes stays
+// the one component map for the router, the tests and every view.
+const ROUTE_PATTERN: Record = {
+ bridge: "/",
+ channel: "/channel/:channelId",
+ topic: "/channel/:channelId/topic/:topicId",
+ agent: "/agent/:agentId",
+ backlog: "/backlog",
+ done: "/done",
+ settings: "/settings",
+};
+const CATCH_ALL = "*all";
+
+function routeComponent(pattern: string): Component {
+ const route = appRoutes.find((entry) => entry.path === pattern);
+ if (!route) throw new Error(`appRoutes has no route for ${pattern}`);
+ return route.component;
+}
+
+// A path that only parses by falling back (unknown, or missing a param) is not
+// canonical; it renders the catch-all so the view is redirected home.
+function isCanonical(path: string): boolean {
+ const trimmed = path.replace(/\/+$/, "") || "/";
+ return routePath(parseRoute(trimmed)) === trimmed;
+}
+
+/** Render one view instance: its scope is the subtree's ViewContext, and its
+ * path picks the component from the shared route table. */
+export const ViewHost: Component<{ scope: ViewScope }> = (props) => {
+ const component = createMemo(() => {
+ const path = props.scope.path();
+ return isCanonical(path)
+ ? routeComponent(ROUTE_PATTERN[props.scope.route().view])
+ : routeComponent(CATCH_ALL);
+ });
+ return (
+
+
+
+ );
+};
diff --git a/apps/ui/src/identity.test.ts b/apps/ui/src/identity.test.ts
index af2d78a6e..9bc935a5d 100644
--- a/apps/ui/src/identity.test.ts
+++ b/apps/ui/src/identity.test.ts
@@ -208,7 +208,7 @@ describe("honest lifecycle vs session optionality", () => {
store.openAgent(candidate.account.id);
flush();
expect(store.selectedAgentId()).toBe(candidate.account.id);
- expect(store.agentSession()).toBeUndefined();
+ expect(store.focusedView().agentSession()).toBeUndefined();
});
});
});
diff --git a/apps/ui/src/routing.test.tsx b/apps/ui/src/routing.test.tsx
index 2ba6bef66..794b2cf53 100644
--- a/apps/ui/src/routing.test.tsx
+++ b/apps/ui/src/routing.test.tsx
@@ -123,9 +123,10 @@ describe("pending-aware channel deep-link (record A3)", () => {
messagesByChannel: {},
});
let store!: AppStore;
+ const history = memoryHistory(initialPath);
const Router = createRouter({
routes: appRoutes,
- history: memoryHistory(initialPath),
+ history,
});
const { container } = render(() => {
store = createAppStore({
@@ -139,7 +140,7 @@ describe("pending-aware channel deep-link (record A3)", () => {
);
});
- return { store, container, fake };
+ return { store, container, fake, history };
}
// A deep-link to a valid channel that the first snapshot DOES carry: it is
@@ -171,14 +172,16 @@ describe("pending-aware channel deep-link (record A3)", () => {
// selection settles onto the snapshot's first channel and the route leaves
// the dead id.
test("an absent deep-link is redirected after the first snapshot", async () => {
- const { store, fake } = mountLive("/channel/chan-gone", [DEEP]);
+ const { store, fake, history } = mountLive("/channel/chan-gone", [DEEP]);
- // Drain the snapshot round-trip: adoptComms fires, sees the current route
- // names a vanished channel, and re-points it.
+ // Drain the snapshot round-trip: the view's pending-aware effect sees the
+ // route names a vanished channel and falls back.
for (let i = 0; i < 30; i++) await Promise.resolve();
await flush();
expect(store.selectedChannelId()).toBe(DEEP);
+ // The fallback reaches the URL, so a reload no longer names the dead id.
+ expect(history.get()).toBe(`/channel/${DEEP}`);
fake.close();
});
diff --git a/apps/ui/src/store.live.test.ts b/apps/ui/src/store.live.test.ts
index 8cd485987..19b4904ae 100644
--- a/apps/ui/src/store.live.test.ts
+++ b/apps/ui/src/store.live.test.ts
@@ -1011,7 +1011,7 @@ describe("store stopAgent (StopAgentSession)", () => {
try {
store.openAgent(runningAgentId());
flush();
- expect(store.agentSession()?.fixture).toBe(true);
+ expect(store.focusedView().agentSession()?.fixture).toBe(true);
await store.stopAgent();
@@ -1040,7 +1040,7 @@ describe("store stopAgent (StopAgentSession)", () => {
try {
store.openAgent(agentId);
flush();
- const session = store.agentSession();
+ const session = store.focusedView().agentSession();
expect(session).toBeDefined();
expect(session?.fixture).toBeUndefined();
diff --git a/apps/ui/src/store.test.ts b/apps/ui/src/store.test.ts
index bf0be62bb..32e702933 100644
--- a/apps/ui/src/store.test.ts
+++ b/apps/ui/src/store.test.ts
@@ -1,20 +1,17 @@
import { describe, expect, test } from "bun:test";
import { createRoot, flush } from "solid-js";
-import type { Ask, AskQuestion } from "./comms-stub";
-import { STUB_CHANNELS, STUB_COMMS_STATE, STUB_MESSAGES } from "./comms-stub";
import {
type AgentTab,
- type AppStore,
- CALLER_ID,
CHAT_TAB_ID,
- createAppStore,
type Pane,
type SplitNode,
splitPaneIds,
splitPaneOnce,
splitPanes,
- type View,
-} from "./store";
+} from "./agent-tabs";
+import type { Ask, AskQuestion } from "./comms-stub";
+import { STUB_CHANNELS, STUB_COMMS_STATE, STUB_MESSAGES } from "./comms-stub";
+import { type AppStore, CALLER_ID, createAppStore, type View } from "./store";
import { STUB_AGENTS, STUB_ASSIGNED_ISSUES } from "./stub-data";
import { testQueryClient } from "./test-support";
@@ -213,38 +210,59 @@ describe("openAgent", () => {
// Prior workspace with an extra tab open — the switch must clear it.
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
s.openAgent("acc-compass-server");
flush();
// Tabs reset to the lone chat tab, focused on it.
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID]);
- expect(s.activeAgentTabId()).toBe(CHAT_TAB_ID);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID]);
+ expect(s.focusedView().activeAgentTabId()).toBe(CHAT_TAB_ID);
// The workspace pane derives its channel from the agent's home DM.
- expect(s.workspaceChannel()?.id).toBe(home?.account.homeChannelId);
+ expect(s.focusedView().workspaceChannel()?.id).toBe(
+ home?.account.homeChannelId,
+ );
});
});
- // The tab reset keys on the workspace-init guard (agentViewAgentId): re-opening
- // the SAME agent preserves the tabs the user opened. A guard that keyed on
- // selectedAgentId, or no guard at all, would wipe the terminal tab on re-open.
+ // The view keeps its workspace while the route names the same agent, so
+ // re-opening that agent keeps the terminal tab the user opened.
test("re-opening the same agent preserves an opened terminal tab (init-guard)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
// Re-open the same agent (clicking its tree row again).
s.openAgent("acc-compass-ui");
flush();
// The init-guard returned before the reset — the terminal tab survives.
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
});
});
@@ -274,8 +292,7 @@ describe("openAgent", () => {
});
// Re-selecting the current agent is a no-op beyond re-asserting the view: a
- // user who switched the branch must NOT have it snapped back on a re-open. The
- // `agentId === agentViewAgentId()` guard returns before any reset.
+ // user who switched the branch must NOT have it snapped back on a re-open.
test("re-opening the already-selected agent preserves the user's branch context", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
@@ -296,23 +313,22 @@ describe("openAgent", () => {
});
});
- // A board roster move (selectIssue) sets selectedAgentId to a new agent
- // WITHOUT initializing that agent's view. Opening that agent must still reset
- // the selection — the reset keys on agentViewAgentId, not selectedAgentId, so a
- // move can't suppress it and leak the previous agent's selection (PR #467).
- test("opening a different agent after a roster move still resets the selection", () => {
+ // A board pick (selectIssue) while an agent view is focused must not move the
+ // roster off the agent the surface shows; opening the picked agent still anchors.
+ test("opening a different agent after a board pick keeps the picked issue", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
- // Board move to compass-server's issue: selects the agent, no init.
+ // Board pick of compass-server's issue: the focused view stays on compass-ui.
s.selectIssue("ws-1023");
flush();
- expect(s.selectedAgentId()).toBe("acc-compass-server");
+ expect(s.selectedAgentId()).toBe("acc-compass-ui");
+ expect(s.focusedView().agent()?.account.id).toBe("acc-compass-ui");
s.openAgent("acc-compass-server");
flush();
- // The reset keys on agentViewAgentId — the move didn't suppress it.
+ // The board pick is compass-server's own issue, so opening it keeps it.
expect(s.selectedIssueId()).toBe("ws-1023");
});
});
@@ -337,21 +353,19 @@ describe("openAgent", () => {
});
});
- // A cross-agent roster move sets selectedAgentId to another agent WITHOUT
- // initializing the view — agentViewAgentId stays on the opened agent. Re-opening
- // the agent-view agent hits the early-return path, which must re-anchor to an
- // OWNED issue — the other agent's ws must not leak (greptile's finding).
- test("re-opening the agent-view agent after a cross-agent roster move re-anchors the issue", () => {
+ // A cross-agent board pick leaves the agent view in place. Re-opening that
+ // agent must re-anchor to an OWNED issue so the other agent's ws can't leak.
+ test("re-opening the agent-view agent after a cross-agent board pick re-anchors the issue", () => {
withStore((s) => {
- s.openAgent("acc-compass-ui"); // agentViewAgentId = compass-ui
+ s.openAgent("acc-compass-ui");
flush();
expect(s.selectedIssueId()).toBe("ws-1022");
s.selectIssue("ws-1023"); // compass-server's ws; view still compass-ui
flush();
- expect(s.selectedAgentId()).toBe("acc-compass-server");
+ expect(s.selectedAgentId()).toBe("acc-compass-ui");
- s.openAgent("acc-compass-ui"); // early-return path (compass-ui is still agentViewAgentId)
+ s.openAgent("acc-compass-ui");
flush();
// Re-anchored to a compass-ui-owned ws — compass-server's ws-1023 did NOT leak.
@@ -360,17 +374,11 @@ describe("openAgent", () => {
});
});
- // The workspace chat pane and the standalone channel surface are decoupled by
- // construction: the pane renders
- // (derived from the selected agent's home DM), while `selectedChannelId` is the
- // standalone surface's own state. `openAgent` no longer writes
- // `selectedChannelId` on either path — so a standalone channel opened in
- // between can never bleed into the workspace, and re-opening the already-open
- // agent (early-return path) restores the agent view without disturbing the
- // standalone selection (PR #783 / RIG-1195).
+ // The workspace pane shows the agent's home DM, while `selectedChannelId` keeps
+ // the last-visited channel across an agent route (no bleed either way).
test("re-opening the agent-view agent shows its home DM while leaving the standalone selection intact", () => {
withStore((s) => {
- s.openAgent("acc-compass-ui"); // agentViewAgentId = compass-ui
+ s.openAgent("acc-compass-ui");
const home = STUB_AGENTS.find((a) => a.account.id === "acc-compass-ui")
?.account.homeChannelId;
expect(home).toBeDefined();
@@ -382,43 +390,19 @@ describe("openAgent", () => {
expect(s.view()).toBe("channel");
expect(s.selectedChannelId()).toBe("ch-svc-compass");
- // Re-open the same agent → early-return path (compass-ui is still
- // agentViewAgentId).
+ // Re-open the same agent.
s.openAgent("acc-compass-ui");
flush();
// The workspace pane derives compass-ui's home DM, and the view is restored.
expect(s.view()).toBe("agent");
- expect(s.workspaceChannel()?.id).toBe(home);
+ expect(s.focusedView().workspaceChannel()?.id).toBe(home);
// The decouple's whole point: openAgent left `selectedChannelId`
// untouched — the standalone selection persists alongside the
// workspace's independent home-DM channel.
expect(s.selectedChannelId()).toBe("ch-svc-compass");
});
});
-
- // The reverse independence: moving the standalone channel surface
- // (`openChannel` on a non-DM channel) cannot move the workspace pane. The
- // workspace channel stays derived from the selected agent's home DM while
- // `selectedChannel` follows the standalone pick — the two surfaces read
- // separate state.
- test("a standalone channel selection does not move the workspace channel", () => {
- withStore((s) => {
- s.openAgent("acc-compass-ui");
- const home = STUB_AGENTS.find((a) => a.account.id === "acc-compass-ui")
- ?.account.homeChannelId;
- expect(home).toBeDefined();
-
- // Move the standalone surface to a channel distinct from compass-ui's home DM.
- s.openChannel("ch-svc-compass");
- flush();
-
- // The standalone surface moved…
- expect(s.selectedChannel()?.id).toBe("ch-svc-compass");
- // …but the workspace pane still derives compass-ui's home DM.
- expect(s.workspaceChannel()?.id).toBe(home);
- });
- });
});
describe("agentRepos memo (T6)", () => {
@@ -1052,7 +1036,7 @@ describe("agent tab group (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- const tabs = s.agentTabs();
+ const tabs = s.focusedView().agentTabs();
expect(tabs).toHaveLength(1);
expect(tabs[0]?.id).toBe(CHAT_TAB_ID);
expect(tabs[0]?.title).toBe("Chat");
@@ -1076,12 +1060,17 @@ describe("agent tab group (T7)", () => {
terminalId: "t1",
};
- s.openTab(pane);
+ s.focusedView().openTab(pane);
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
- expect(s.activeAgentTabId()).toBe("term-1");
- const tab = s.activeAgentTab();
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
+ const tab = s.focusedView().activeAgentTab();
expect(tab?.title).toBe("dev");
expect(tab?.layout).toEqual({ kind: "leaf", pane });
expect(tab?.focusedPaneId).toBe("term-1");
@@ -1093,22 +1082,27 @@ describe("agent tab group (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.openTab({ id: "term-2", kind: "terminal", title: "tests" });
+ s.focusedView().openTab({
+ id: "term-2",
+ kind: "terminal",
+ title: "tests",
+ });
flush();
- expect(s.activeAgentTabId()).toBe("term-2");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-2");
// Re-open term-1 (already present): no new tab, focus moves back to it.
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([
- CHAT_TAB_ID,
- "term-1",
- "term-2",
- ]);
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1", "term-2"]);
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
});
});
@@ -1117,20 +1111,20 @@ describe("agent tab group (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
- s.setActiveAgentTab("does-not-exist");
+ s.focusedView().setActiveAgentTab("does-not-exist");
flush();
// Focus unchanged: the guard rejected the unknown id.
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
});
});
});
-describe("splitActivePane (T7)", () => {
+describe("splitFocused (T7)", () => {
// Splitting the active tab's focused pane grows THAT tab's tree by one pane
// and focuses the new pane so a follow-up split chains off it. `row` places
// the new pane to the right (split right).
@@ -1140,10 +1134,10 @@ describe("splitActivePane (T7)", () => {
flush();
const pane: Pane = { id: "term-1", kind: "terminal", title: "dev" };
- s.splitActivePane(pane, "row");
+ s.focusedView().splitFocused(pane, "row");
flush();
- const tab = s.activeAgentTab();
+ const tab = s.focusedView().activeAgentTab();
expect(tab?.layout).toEqual({
kind: "split",
direction: "row",
@@ -1165,13 +1159,13 @@ describe("splitActivePane (T7)", () => {
s.openAgent("acc-compass-ui");
flush();
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-1", kind: "terminal", title: "dev" },
"column",
);
flush();
- expect(s.activeAgentTab()?.layout).toEqual({
+ expect(s.focusedView().activeAgentTab()?.layout).toEqual({
kind: "split",
direction: "column",
left: {
@@ -1193,21 +1187,25 @@ describe("splitActivePane (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.openTab({ id: "term-2", kind: "terminal", title: "tests" });
+ s.focusedView().openTab({
+ id: "term-2",
+ kind: "terminal",
+ title: "tests",
+ });
flush();
// Make term-1 the active tab, then split it.
- s.setActiveAgentTab("term-1");
+ s.focusedView().setActiveAgentTab("term-1");
flush();
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-3", kind: "terminal", title: "logs" },
"row",
);
flush();
- const tabs = s.agentTabs();
+ const tabs = s.focusedView().agentTabs();
const term1 = tabs.find((t) => t.id === "term-1");
expect(paneIds(term1)).toEqual(["term-1", "term-3"]);
expect(term1?.focusedPaneId).toBe("term-3");
@@ -1228,34 +1226,34 @@ describe("splitActivePane (T7)", () => {
s.openAgent("acc-compass-ui");
flush();
// Split once: panes [chat, term-1], term-1 focused.
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-1", kind: "terminal", title: "dev" },
"row",
);
flush();
- expect(s.activeAgentTab()?.focusedPaneId).toBe("term-1");
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe("term-1");
// Focus back to the chat pane; an absent id changes nothing.
- s.setFocusedPane(CHAT_TAB_ID);
+ s.focusedView().setFocusedPane(CHAT_TAB_ID);
flush();
- expect(s.activeAgentTab()?.focusedPaneId).toBe(CHAT_TAB_ID);
- s.setFocusedPane("not-a-pane");
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe(CHAT_TAB_ID);
+ s.focusedView().setFocusedPane("not-a-pane");
flush();
- expect(s.activeAgentTab()?.focusedPaneId).toBe(CHAT_TAB_ID);
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe(CHAT_TAB_ID);
// The next split anchors on the chat pane, inserting term-2 beside
// it (not beside term-1), and focuses term-2.
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-2", kind: "terminal", title: "tests" },
"column",
);
flush();
- expect(paneIds(s.activeAgentTab())).toEqual([
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual([
CHAT_TAB_ID,
"term-2",
"term-1",
]);
- expect(s.activeAgentTab()?.focusedPaneId).toBe("term-2");
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe("term-2");
});
});
});
@@ -1267,17 +1265,26 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.openTab({ id: "term-2", kind: "terminal", title: "tests" });
+ s.focusedView().openTab({
+ id: "term-2",
+ kind: "terminal",
+ title: "tests",
+ });
flush();
- expect(s.activeAgentTabId()).toBe("term-2");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-2");
- s.closeTab("term-2");
+ s.focusedView().closeTab("term-2");
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
- expect(s.activeAgentTabId()).toBe(CHAT_TAB_ID);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(s.focusedView().activeAgentTabId()).toBe(CHAT_TAB_ID);
});
});
@@ -1287,18 +1294,27 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.openTab({ id: "term-2", kind: "terminal", title: "tests" });
+ s.focusedView().openTab({
+ id: "term-2",
+ kind: "terminal",
+ title: "tests",
+ });
flush();
- s.setActiveAgentTab("term-2");
+ s.focusedView().setActiveAgentTab("term-2");
flush();
- s.closeTab("term-1");
+ s.focusedView().closeTab("term-1");
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-2"]);
- expect(s.activeAgentTabId()).toBe("term-2");
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-2"]);
+ expect(s.focusedView().activeAgentTabId()).toBe("term-2");
});
});
@@ -1307,15 +1323,20 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
- s.closeTab(CHAT_TAB_ID);
+ s.focusedView().closeTab(CHAT_TAB_ID);
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
});
});
@@ -1325,21 +1346,29 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-2", kind: "terminal", title: "tests" },
"row",
);
flush();
- expect(paneIds(s.activeAgentTab())).toEqual(["term-1", "term-2"]);
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual([
+ "term-1",
+ "term-2",
+ ]);
- s.closePane("term-1");
+ s.focusedView().closePane("term-1");
flush();
// The tab survives; its tree collapsed to the lone surviving pane.
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID, "term-1"]);
- expect(s.activeAgentTab()?.layout).toEqual({
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(s.focusedView().activeAgentTab()?.layout).toEqual({
kind: "leaf",
pane: { id: "term-2", kind: "terminal", title: "tests" },
});
@@ -1352,15 +1381,20 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- expect(s.activeAgentTabId()).toBe("term-1");
+ expect(s.focusedView().activeAgentTabId()).toBe("term-1");
- s.closePane("term-1");
+ s.focusedView().closePane("term-1");
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID]);
- expect(s.activeAgentTabId()).toBe(CHAT_TAB_ID);
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID]);
+ expect(s.focusedView().activeAgentTabId()).toBe(CHAT_TAB_ID);
});
});
@@ -1370,20 +1404,20 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.openTab({ id: "term-1", kind: "terminal", title: "dev" });
+ s.focusedView().openTab({ id: "term-1", kind: "terminal", title: "dev" });
flush();
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-2", kind: "terminal", title: "tests" },
"row",
);
flush();
- expect(s.activeAgentTab()?.focusedPaneId).toBe("term-2");
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe("term-2");
- s.closePane("term-2");
+ s.focusedView().closePane("term-2");
flush();
- expect(paneIds(s.activeAgentTab())).toEqual(["term-1"]);
- expect(s.activeAgentTab()?.focusedPaneId).toBe("term-1");
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual(["term-1"]);
+ expect(s.focusedView().activeAgentTab()?.focusedPaneId).toBe("term-1");
});
});
@@ -1396,12 +1430,17 @@ describe("closing tabs + panes (T7)", () => {
s.openAgent("acc-compass-ui");
flush();
- s.closePane(CHAT_TAB_ID);
+ s.focusedView().closePane(CHAT_TAB_ID);
flush();
- expect(s.agentTabs().map((t) => t.id)).toEqual([CHAT_TAB_ID]);
- expect(s.activeAgentTabId()).toBe(CHAT_TAB_ID);
- expect(s.activeAgentTab()?.layout).toEqual({
+ expect(
+ s
+ .focusedView()
+ .agentTabs()
+ .map((t) => t.id),
+ ).toEqual([CHAT_TAB_ID]);
+ expect(s.focusedView().activeAgentTabId()).toBe(CHAT_TAB_ID);
+ expect(s.focusedView().activeAgentTab()?.layout).toEqual({
kind: "leaf",
pane: { id: CHAT_TAB_ID, kind: "chat", title: "Chat" },
});
@@ -1417,19 +1456,25 @@ describe("closing tabs + panes (T7)", () => {
s.openAgent("acc-compass-ui");
flush();
// Split the chat tab: chat pane + term-1 beside it.
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-1", kind: "terminal", title: "dev" },
"row",
);
flush();
// Sanity: the chat tab now holds both panes.
- expect(paneIds(s.activeAgentTab())).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual([
+ CHAT_TAB_ID,
+ "term-1",
+ ]);
- s.closePane(CHAT_TAB_ID); // permanent — no-op
+ s.focusedView().closePane(CHAT_TAB_ID); // permanent — no-op
flush();
// Both panes survive; the split is intact.
- expect(paneIds(s.activeAgentTab())).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual([
+ CHAT_TAB_ID,
+ "term-1",
+ ]);
});
});
@@ -1441,18 +1486,21 @@ describe("closing tabs + panes (T7)", () => {
withStore((s) => {
s.openAgent("acc-compass-ui");
flush();
- s.splitActivePane(
+ s.focusedView().splitFocused(
{ id: "term-1", kind: "terminal", title: "dev" },
"row",
);
flush();
- expect(paneIds(s.activeAgentTab())).toEqual([CHAT_TAB_ID, "term-1"]);
+ expect(paneIds(s.focusedView().activeAgentTab())).toEqual([
+ CHAT_TAB_ID,
+ "term-1",
+ ]);
- s.closePane("term-1");
+ s.focusedView().closePane("term-1");
flush();
// The split collapses back to the lone chat pane.
- expect(s.activeAgentTab()?.layout).toEqual({
+ expect(s.focusedView().activeAgentTab()?.layout).toEqual({
kind: "leaf",
pane: { id: CHAT_TAB_ID, kind: "chat", title: "Chat" },
});
diff --git a/apps/ui/src/store.ts b/apps/ui/src/store.ts
index b3f1e0568..09a2649a6 100644
--- a/apps/ui/src/store.ts
+++ b/apps/ui/src/store.ts
@@ -23,8 +23,9 @@ import {
getOwner,
onCleanup,
} from "solid-js";
+import type { Pane } from "./agent-tabs";
import { type PrRow, prRows } from "./board";
-import { agentDmAccountId } from "./comms";
+import { agentDmAccountId, firstChannelId } from "./comms";
import {
type Account,
type Ask,
@@ -70,16 +71,8 @@ import {
type TrackerSeam,
} from "./tracker";
import { parseRoute } from "./view-route";
+import { createViewScope, type ViewScope } from "./view-scope";
-// Open on the first subscribed channel so the shell boots into a live
-// conversation; null before the first snapshot (components render empty state).
-export function firstChannelId(channels: readonly Channel[]): string | null {
- return (
- channels.find((c) => c.membership === "subscribed")?.id ??
- channels[0]?.id ??
- null
- );
-}
/** The caller — whose visibility scopes every listing and whose membership the
* rail reflects. The daemon derives this from the authenticated connection
* (comms.proto: "the caller is the account authenticated on the connection");
@@ -133,130 +126,6 @@ export interface RepoClone {
currentBranch: string;
}
-/** 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. */
-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. */
-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];
-}
-
/** A candidate in a stable name's chain; order is its fallback order. */
export interface ModelRegistryCandidate {
readonly provider: string;
@@ -305,7 +174,10 @@ export function modelRegistryRows(
*/
export interface AppStore {
// ── View routing ──
- /** The active top-level view. */
+ /** The view the window chrome reads routed selection through. One view
+ * per window for now; the router's path is applied to it. */
+ focusedView: Accessor;
+ /** The focused view's top-level surface. */
view: Accessor;
/** Jump to the Bridge board. */
showBridge: () => void;
@@ -350,7 +222,8 @@ export interface AppStore {
readonly keyboard: KeyboardSpine;
// ── Selection ──
- /** The selected agent id, or null. Drives the agent view + roster highlight. */
+ /** The roster's selected agent: the focused agent view's agent, moved by a
+ * board pick (`selectIssue`) without leaving the board. */
selectedAgentId: Accessor;
/** The selected issue id, or null. Drives the detail + right sidebar. */
selectedIssueId: Accessor;
@@ -425,7 +298,7 @@ export interface AppStore {
/** Repo clones present in the selected agent's container, for the repo/branch
* dropdown. Empty when no agent is selected. */
agentRepos: Accessor;
- /** The active repo id within the selected agent's clones, or null. */
+ /** The repo clone picked in the focused view's workspace, or null. */
activeRepoId: Accessor;
/** The resolved active repo, or undefined. */
activeRepo: Accessor;
@@ -467,25 +340,19 @@ export interface AppStore {
messages: Accessor;
/** All topics visible to the caller — the reactive topic-index source. */
topics: Accessor;
- /** The selected channel id, or null (the empty state before a pick). */
+ /** The focused view's channel, else the last one visited, else the first
+ * subscribed (the boot default); null before any channel is known. */
selectedChannelId: Accessor;
/** The resolved selected channel, or undefined. */
selectedChannel: Accessor;
- /** The selected topic id, or null (no topic drilled into). Set by the
- * `/channel/:channelId/topic/:topicId` route via applyTopicRoute. */
+ /** The focused topic view's topic id, or null off a topic route. */
selectedTopicId: Accessor;
/** The resolved selected topic, or undefined. */
selectedTopic: Accessor;
/** Drill into a topic's message view — navigate to
- * `/channel//topic/`; the route-sync effect
- * (applyTopicRoute) writes view + selection. Resolves the topic's channel
+ * `/channel//topic/`. Resolves the topic's channel
* off the topic set; a no-op on an unknown topic id. */
openTopic: (topicId: string) => void;
- /** The agent workspace's chat channel — the selected agent's home DM,
- * derived off the account, independent of `selectedChannel` so the
- * standalone surface can't re-point the workspace pane. Undefined when no
- * agent is selected. */
- workspaceChannel: Accessor;
/** NOT WIRED YET — inert. The wire has no join RPC; the rail's join control
* renders disabled. Kept as the seam the control binds to (and where the
* RPC lands), but it fakes NO membership: a local-only join silently
@@ -550,51 +417,20 @@ export interface AppStore {
text: string,
) => Promise;
- // ── Agent view: tabs (pane groups) + per-tab split trees (T7) ──
- /** The selected agent's live session: the typed AgentSession (its ordered
- * SessionEvent stream + running flag), or undefined when no agent is selected
- * / it has no session. Compass folds and renders these typed events (design:
- * architecture-lineage); `running` drives the Stop control's enablement. */
- agentSession: Accessor;
- /** The open tabs (chat first, never closable; design D6). Each tab is a
- * group of panes with its own split tree. Empty when no agent is selected.
- * Terminals are not auto-opened — a fresh agent shows only the chat. */
- agentTabs: Accessor;
- /** The active tab id (the tab shown full-screen), or null when no agent is
- * selected. */
- activeAgentTabId: Accessor;
- /** The resolved active tab, or undefined. */
- activeAgentTab: Accessor;
- /** Switch which tab is shown. No-op for an unknown id. */
- setActiveAgentTab: (tabId: string) => void;
- /** Open a new full-screen tab and focus it. The MVP opens a terminal tab
- * (later: a context menu picks terminal / markdown / file, design D6). A tab
- * starts with its one pane full-screen. Re-opening a pane already shown as a
- * tab just focuses it (id-deduped). */
- openTab: (pane: Pane) => void;
+ // ── Agent sessions + terminal panes ──
+ /** An agent's live session: the typed AgentSession (its ordered
+ * SessionEvent stream + running flag), or undefined when it has none.
+ * Window-wide data; each view looks up its own agent's session. */
+ agentSessionById: (agentId: string) => AgentSession | undefined;
/** Mint a fresh placeholder terminal pane for an agent — a brand-new pane
* with a globally-unique id (monotonic counter), used to keep "new tab" and
* "split" always available once the agent's fixture terminals are all placed.
* Its `terminalId` intentionally matches no fixture (the pane starts empty
* until the daemon attaches a real terminal). */
newTerminalPane: (agent: Agent) => Pane;
- /** Close a tab and drop it; the chat tab can't be closed. Focus falls back
- * to the chat tab. */
- closeTab: (tabId: string) => void;
- /** Split the active tab's focused pane, adding `pane` beside it — `row` =
- * split right, `column` = split down (design D6). The new pane becomes the
- * tab's focused pane. No-op when no agent/tab is active. */
- splitActivePane: (pane: Pane, direction: "row" | "column") => void;
- /** Focus a pane within the active tab (the pane the split buttons act on).
- * No-op for a pane not in the active tab. */
- setFocusedPane: (paneId: string) => void;
- /** Close a pane within the active tab, collapsing its split. Closing the last
- * pane in a non-chat tab closes the tab; the chat pane in the chat tab is
- * permanent. */
- closePane: (paneId: string) => void;
- /** Stop the selected agent (the workspace's stop control). Steering happens
- * in the channel, not here — this is the one non-observational control.
- * Issues StopAgentSession for the OBSERVED session (`agentSession()`), a
+ /** Stop the focused view's agent (the workspace's stop control). Steering
+ * happens in the channel, not here — this is the one non-observational
+ * control. Issues StopAgentSession for the focused view's session, a
* no-op when nothing is selected. Resolves either way: the RPC is
* Runner-backed and answers `Unavailable` when the server has no RunnerHub
* attached (the socket-only path), so a refusal is routed to
@@ -783,10 +619,9 @@ export function createAppStore(options: AppStoreOptions): AppStore {
...(options.initialIssues ?? STUB_ISSUES),
]);
- const [view, setView] = createSignal("bridge");
- const [selectedAgentId, setSelectedAgentId] = createSignal(
- null,
- );
+ // A board pick's agent (`selectIssue`). On an agent route the focused view's
+ // agent wins, so the roster and chrome never disagree with the agent surface.
+ const [pickedAgentId, setPickedAgentId] = createSignal(null);
// Default to the first issue so the seam survives swapping the fixture
// for the real @compass/client (no hardcoded stub id); an empty board
// starts with no selection.
@@ -795,10 +630,8 @@ export function createAppStore(options: AppStoreOptions): AppStore {
);
// ── Router seam (record A3): routes are the source of truth ──────────────
- // The routed dimension (view + identifying param) is driven by the URL. The store
- // lives outside the router tree, so the router is INJECTED as a seam. A default
- // in-memory seam applies routes SYNCHRONOUSLY until App swaps in @solidjs/router.
- const [inMemoryPath, setInMemoryPath] = createSignal("/");
+ // The store lives outside the router tree, so the router is INJECTED. Until App
+ // binds it, navigation applies straight to the focused view, synchronously.
let routerNavigate = (path: string): void => {
// Pre-bind navigation: nothing in production navigates before App binds (comms
// stream and assigned-issues query never navigate), so reaching here in a dev
@@ -811,32 +644,24 @@ export function createAppStore(options: AppStoreOptions): AppStore {
"update until App wires the router",
);
}
- setInMemoryPath(path);
- applyRoute(path);
+ applyFocusedPath(path);
};
- let routerCurrentPath = (): string => inMemoryPath();
const navigateTo = (path: string): void => routerNavigate(path);
- const currentPath = (): string => routerCurrentPath();
- // Wire the real router (once, from App). The route-sync effect is the SINGLE writer
- // of the routed dimension. Its compute tracks the path AND the resolution inputs
- // (`firstSnapshotArrived`, `channels`, `topics`), so a deep-link onto an async surface
- // holds until the first snapshot lands, then re-fires applyRoute — routes-as-truth.
+ // Wire the real router (once, from App). The URL is applied to the focused view,
+ // and a redirect the view makes itself (an unknown id after the first snapshot)
+ // is pushed back to the URL, so the two never drift.
const bindRouter = (r: {
navigate: (path: string) => void;
currentPath: () => string;
}): void => {
routerNavigate = r.navigate;
- routerCurrentPath = r.currentPath;
+ createEffect(r.currentPath, (path) => applyFocusedPath(path));
createEffect(
- () => {
- const path = r.currentPath();
- // Tracked so a snapshot arrival re-resolves the held route.
- firstSnapshotArrived();
- channels();
- topics();
- return path;
+ () => focusedView().path(),
+ (path, prev) => {
+ // The first run is the boot path, before the URL has been applied.
+ if (prev !== undefined && path !== r.currentPath()) r.navigate(path);
},
- (path) => applyRoute(path),
);
};
@@ -920,11 +745,6 @@ export function createAppStore(options: AppStoreOptions): AppStore {
// presence/account tick flips resolution live→reachable through this one seam.
const agentById = (accountId: string): Agent | undefined =>
agents().find((a) => a.account.id === accountId);
- // The active repo id (T6). The current branch is derived from the selected
- // issue (see agentRepos), so there's no separate branch-pick signal to
- // drift from the panes.
- const [activeRepoId, setActiveRepoId] = createSignal(null);
-
// ── Comms: the channel surface (design: architecture-lineage) ──
// ONE reduced CommsState drives all four comms accessors, starting at `initialComms`
// (EMPTY by default) and replaced wholesale by each push — bar the local ask picks
@@ -971,38 +791,18 @@ export function createAppStore(options: AppStoreOptions): AppStore {
const setActiveRightTab = (tab: RightSidebarTab) => {
setActiveRightTabRaw(tab);
};
- const [selectedChannelId, setSelectedChannelId] = createSignal(
- firstChannelId(comms().channels),
- );
- // True once the first comms snapshot has arrived from the stream. The
- // pending-aware route fallback (applyChannelRoute) reads it: before the first
- // snapshot an absent channel id is merely not-yet-loaded (held, not bounced);
- // after it, an absent id is genuinely unknown (redirected).
+ // True once the first comms snapshot has arrived from the stream. A view's
+ // pending-aware route fallback reads it: before the first snapshot an absent
+ // channel id is merely not-yet-loaded (held, not bounced); after it, an absent
+ // id is genuinely unknown (redirected).
const [firstSnapshotArrived, setFirstSnapshotArrived] = createSignal(false);
- // Adopt a stream-pushed state and settle the selection: the user's explicit pick
- // wins while its channel is visible, so a later push never yanks the surface away;
- // an absent selection falls back to the first subscribed channel. Adopted WHOLESALE
- // except in-progress local ask picks (never sent to server) — see `preserveLocalAsks`.
+ // Adopt a stream-pushed state WHOLESALE except in-progress local ask picks
+ // (never sent to server) — see `preserveLocalAsks`. A routed channel that the
+ // push dropped is re-pointed by the view itself.
const adoptComms = (next: CommsState) => {
setComms((prev) => preserveLocalAsks(prev, next));
setFirstSnapshotArrived(true);
- const current = selectedChannelId();
- if (current && next.channels.some((c) => c.id === current)) return;
- // The selection is absent from the pushed snapshot. Under routes-as-truth the
- // channel surface is a route: if the current route names a channel, re-point it
- // through navigate; then re-seed the signal as the "last visited" fallback.
- const fallback = firstChannelId(next.channels);
- if (currentPath().startsWith("/channel/")) {
- navigateTo(fallback ? `/channel/${fallback}` : "/");
- }
- setSelectedChannelId(fallback);
};
- // The selected topic id on the topic view, or null. Written solely by the
- // route-sync (applyTopicRoute), the single writer of the routed dimension —
- // openTopic navigates, it does not set this directly.
- const [selectedTopicId, setSelectedTopicId] = createSignal(
- null,
- );
// The live read path: run the SubscribeComms driver for the store's lifetime,
// mirroring each reduced state into the signals above. Aborted on teardown.
// `runCommsStream` resolves only on abort and retries internally, so nothing awaits
@@ -1069,24 +869,76 @@ export function createAppStore(options: AppStoreOptions): AppStore {
.slice(2, 10)}`;
let requestCount = 0;
- // ── Agent view (T7): tabs (pane groups) + active tab ──
- // Each tab owns its own split tree of panes and focused pane. A fresh agent shows
- // only the chat tab (terminals hidden, D6). Empty until an agent is opened.
- const [tabs, setTabs] = createSignal([]);
- const [activeAgentTabId, setActiveAgentTabId] = createSignal(
- null,
- );
- // The agent id the agent-view state (tabs/split/branch) was last initialized for —
- // distinct from `selectedAgentId`, which the board's `selectIssue` moves without
- // initializing. `openAgent` keys its reset on THIS, so a roster move then open still inits.
- const [agentViewAgentId, setAgentViewAgentId] = createSignal(
- null,
- );
// Monotonic counter for MINTED placeholder terminal panes (never reused, so ids
- // stay globally unique across opens/closes). Not reactive: a plain counter. The
- // daemon will assign real terminal ids at this same seam later.
+ // stay globally unique across opens/closes and across views). Not reactive: a
+ // plain counter. The daemon will assign real terminal ids at this same seam later.
let mintedTerminalCount = 0;
+ // Each agent's live session trace, or undefined when none. Sourced from the fixture
+ // unless the caller supplies sessions — fixture entries carry `fixture: true`,
+ // keeping their never-minted ids off the wire (see `stopAgent`).
+ const sessions = options.sessions ?? STUB_SESSION_EVENTS;
+ const agentSessionById = (agentId: string): AgentSession | undefined =>
+ sessions[agentId];
+
+ // ── The focused view (record A1): the one view per window. The router's path is
+ // applied to it, and the routed accessors the chrome reads derive from it. ──
+ const focused = createViewScope(
+ { channels, topics, agentById, agentSessionById, firstSnapshotArrived },
+ "main",
+ "/",
+ );
+ const focusedView = () => focused;
+ const view = createMemo(() => focused.route().view);
+ // Last-visited: an agent or board route keeps the channel the user left, and a
+ // channel the push dropped falls back to the first subscribed one.
+ const selectedChannelId = createMemo((prev) => {
+ const match = focused.route();
+ if (match.view === "channel" || match.view === "topic") {
+ return match.channelId;
+ }
+ const known = channels();
+ return prev && known.some((c) => c.id === prev)
+ ? prev
+ : firstChannelId(known);
+ });
+ const selectedTopicId = createMemo(() => {
+ const match = focused.route();
+ return match.view === "topic" ? match.topicId : null;
+ });
+ const selectedChannel = createMemo(() =>
+ channels().find((c) => c.id === selectedChannelId()),
+ );
+ const selectedTopic = createMemo(() =>
+ topics().find((t) => t.id === selectedTopicId()),
+ );
+ // The agent the log panel was last reset open for, so re-entering the same
+ // agent keeps the user's minimize.
+ let logResetForAgentId: string | null = null;
+ // Apply a path to the focused view. Entering an agent anchors the window-wide
+ // issue selection on every entry, even onto the same path: keep the current
+ // issue when this agent owns it, else its primary.
+ const applyFocusedPath = (path: string): void => {
+ const match = parseRoute(path);
+ if (match.view === "agent") {
+ const owned = issues().filter((w) => w.assignee === match.agentId);
+ setPickedAgentId(match.agentId);
+ setSelectedIssueId(
+ owned.find((w) => w.id === selectedIssueId())?.id ??
+ owned[0]?.id ??
+ null,
+ );
+ if (match.agentId !== logResetForAgentId) {
+ setLogOpen(true);
+ logResetForAgentId = match.agentId;
+ }
+ }
+ focused.navigate(path);
+ };
+ const selectedAgentId = createMemo(() => {
+ const match = focused.route();
+ return match.view === "agent" ? match.agentId : pickedAgentId();
+ });
const selectedAgent = createMemo(() =>
agents().find((a) => a.account.id === selectedAgentId()),
);
@@ -1124,7 +976,7 @@ export function createAppStore(options: AppStoreOptions): AppStore {
// else the first clone (so a stale pick from a previous agent can't dangle).
const activeRepo = createMemo(() => {
const repos = agentRepos();
- const picked = repos.find((r) => r.id === activeRepoId());
+ const picked = repos.find((r) => r.id === focused.activeRepoId());
return picked ?? repos[0];
});
@@ -1138,131 +990,9 @@ export function createAppStore(options: AppStoreOptions): AppStore {
kind: "user",
},
);
- const selectedChannel = createMemo(() =>
- channels().find((c) => c.id === selectedChannelId()),
- );
- const selectedTopic = createMemo(() =>
- topics().find((t) => t.id === selectedTopicId()),
- );
- // The agent workspace's chat channel: the selected agent's home DM, resolved O(1) off
- // the account — NOT `selectedChannel`. Deriving it from the agent keeps the standalone
- // channel surface and the workspace chat pane independent (D3). Undefined when no agent
- // is selected or its home DM isn't in the channel set (real-daemon partial-join).
- const workspaceChannel = createMemo(() => {
- const home = selectedAgent()?.account.homeChannelId;
- return home ? channels().find((c) => c.id === home) : undefined;
- });
- // The selected agent's live session trace, or undefined when none. One id space after
- // T1 makes the observed agent ≡ the selected agent (record §478-481). Sourced from the
- // fixture unless the caller supplies sessions — fixture entries carry `fixture: true`,
- // keeping their never-minted ids off the wire (see `stopAgent`).
- const sessions = options.sessions ?? STUB_SESSION_EVENTS;
- const agentSession = createMemo(() => {
- const id = selectedAgentId();
- return id ? sessions[id] : undefined;
- });
-
- // The agent view's tabs (T7): the chat tab first (always present), then the tabs the
- // user has opened. Empty when no agent is selected; terminals are NOT auto-opened (D6).
- const agentTabs = createMemo(() =>
- selectedAgentId() ? tabs() : [],
- );
- const activeAgentTab = createMemo(() =>
- agentTabs().find((t) => t.id === activeAgentTabId()),
- );
- // ── Route application (record A3): the single writer of the routed dimension
- // (view + selectedChannelId/selectedAgentId). Invoked synchronously by the default
- // seam (offline) or the bound route-sync effect. It parses currentPath itself — the
- // store is outside the router tree and cannot call useParams. ──
- function applyRoute(path: string): void {
- const match = parseRoute(path);
- switch (match.view) {
- case "bridge":
- case "backlog":
- case "done":
- case "settings":
- setView(match.view);
- return;
- case "channel":
- applyChannelRoute(match.channelId);
- return;
- case "topic":
- applyTopicRoute(match.channelId, match.topicId);
- return;
- case "agent":
- applyAgentRoute(match.agentId);
- return;
- }
- }
- // Apply a `/channel/:channelId` route. Pending-aware: a not-yet-loaded id is HELD
- // (empty state), NOT bounced — a valid deep-link must survive boot. Only after the
- // first snapshot is an absent id redirected. Does NOT clear selectedTopicId: that
- // dimension has one writer (applyTopicRoute) and stale reads are view()-guarded.
- function applyChannelRoute(channelId: string): void {
- setView("channel");
- if (channels().some((c) => c.id === channelId)) {
- setSelectedChannelId(channelId);
- return;
- }
- if (firstSnapshotArrived()) {
- const fallback = firstChannelId(comms().channels);
- navigateTo(fallback ? `/channel/${fallback}` : "/");
- return;
- }
- setSelectedChannelId(channelId);
- }
- // Apply a `/channel/:id/topic/:id` route — the topic message view, SOLE writer of the
- // topic dimension. Follows applyChannelRoute's pending-aware pattern: channel held
- // while not-yet-loaded, bounced once the snapshot arrives and it is absent; topic id
- // held so a deep-link survives boot, an absent topic after snapshot falls to the index.
- function applyTopicRoute(channelId: string, topicId: string): void {
- setView("topic");
- const channelKnown = channels().some((c) => c.id === channelId);
- if (!channelKnown && firstSnapshotArrived()) {
- const fallback = firstChannelId(comms().channels);
- navigateTo(fallback ? `/channel/${fallback}` : "/");
- return;
- }
- setSelectedChannelId(channelId);
- if (topics().some((t) => t.id === topicId)) {
- setSelectedTopicId(topicId);
- return;
- }
- if (firstSnapshotArrived()) {
- // The topic is genuinely unknown — drop back to the channel's index.
- setView("channel");
- setSelectedTopicId(null);
- navigateTo(`/channel/${channelId}`);
- return;
- }
- setSelectedTopicId(topicId);
- }
- // Apply an `/agent/:agentId` route — the workspace anchoring lifted verbatim from the
- // old openAgent so click and deep-link run the SAME code. Anchor issue selection: keep
- // the current when this agent owns it, else its primary. The reset guard keys on
- // `agentViewAgentId`, so a roster move then open still initializes the view.
- function applyAgentRoute(agentId: string): void {
- setView("agent");
- const owned = issues().filter((w) => w.assignee === agentId);
- const anchored =
- owned.find((w) => w.id === selectedIssueId())?.id ?? owned[0]?.id ?? null;
- if (agentId === agentViewAgentId()) {
- setSelectedAgentId(agentId);
- setSelectedIssueId(anchored);
- return;
- }
- setSelectedAgentId(agentId);
- setSelectedIssueId(anchored);
- setActiveRepoId(`${agentId}-repo`);
- setTabs([chatTab()]);
- setActiveAgentTabId(CHAT_TAB_ID);
- setLogOpen(true);
- setAgentViewAgentId(agentId);
- }
-
- // Open an agent's workspace: navigate — the route-sync effect (applyAgentRoute) runs
- // the anchoring, so the click path and a `/agent/:agentId` deep-link share one home.
+ // Open an agent's workspace by navigating, so the click and a `/agent/:agentId`
+ // deep-link share one home (applyFocusedPath).
const openAgent = (agentId: string) => {
navigateTo(`/agent/${agentId}`);
};
@@ -1282,10 +1012,9 @@ export function createAppStore(options: AppStoreOptions): AppStore {
navigateTo(`/channel/${channelId}`);
};
- // Drill into a topic's message view: navigate to `/channel//topic/` — the
- // route-sync effect (applyTopicRoute) is the single writer of view + selection, so
- // click and deep-link share one home. Resolves the channel off the topic set; a
- // no-op on an unknown topic id. NEVER setView — navigation is the sole entry.
+ // Drill into a topic's message view by navigating to `/channel//topic/`,
+ // so click and deep-link share one home. Resolves the channel off the topic set;
+ // a no-op on an unknown topic id.
const openTopic = (topicId: string) => {
const topic = topics().find((t) => t.id === topicId);
if (!topic) return;
@@ -1298,7 +1027,7 @@ export function createAppStore(options: AppStoreOptions): AppStore {
const selectIssue = (issueId: string) => {
setSelectedIssueId(issueId);
const ws = issues().find((w) => w.id === issueId);
- setSelectedAgentId(ws?.assignee ?? null);
+ setPickedAgentId(ws?.assignee ?? null);
};
// ── Comms mutations (design: architecture-lineage) ──
@@ -1619,104 +1348,15 @@ export function createAppStore(options: AppStoreOptions): AppStore {
});
};
- // ── Agent view actions (T7) ──
- const setActiveAgentTab = (tabId: string) => {
- if (agentTabs().some((t) => t.id === tabId)) setActiveAgentTabId(tabId);
- };
- // Mutate the active tab in place (its split tree / focused pane), leaving the
- // other tabs untouched. A no-op when no tab is active.
- const updateActiveTab = (fn: (tab: AgentTab) => AgentTab) => {
- const id = activeAgentTabId();
- if (!id) return;
- setTabs((prev) => prev.map((t) => (t.id === id ? fn(t) : t)));
- };
- // Open a new full-screen tab holding `pane` and focus it. Re-opening a pane
- // already shown as a tab just focuses that tab (no duplicate). The MVP opens
- // a terminal; later a context menu picks the pane kind (design D6).
- const openTab = (pane: Pane) => {
- if (!selectedAgentId()) return;
- const existing = tabs().find((t) => t.id === pane.id);
- if (!existing) {
- const tab: AgentTab = {
- id: pane.id,
- title: pane.title,
- layout: { kind: "leaf", pane },
- focusedPaneId: pane.id,
- };
- setTabs((prev) => [...prev, tab]);
- }
- setActiveAgentTabId(pane.id);
- };
// Mint a fresh placeholder terminal pane. The counter only increments, so the id
- // (`term--`) is unique across the session — no collision with openTab's
- // dedupe or splitPaneOnce's guard. `terminalId` mirrors the id and matches no fixture:
+ // (`term--`) is unique across the session — no collision with a view's
+ // openTab dedupe or splitPaneOnce's guard. `terminalId` mirrors the id and matches no fixture:
// the pane renders an empty "starting" state until the daemon attaches one.
const newTerminalPane = (agent: Agent): Pane => {
const n = ++mintedTerminalCount;
const id = `term-${agent.account.id}-${n}`;
return { id, kind: "terminal", title: `Terminal ${n}`, terminalId: id };
};
- // Close a tab: drop it and fall focus back to the chat tab. The chat tab is
- // permanent (D6) — closing it is a no-op.
- const closeTab = (tabId: string) => {
- if (tabId === CHAT_TAB_ID) return;
- setTabs((prev) => prev.filter((t) => t.id !== tabId));
- if (activeAgentTabId() === tabId) setActiveAgentTabId(CHAT_TAB_ID);
- };
- // Split the active tab's focused pane, placing `pane` beside it (`row` =
- // split right, `column` = split down). The new pane becomes focused so a
- // follow-up split chains off it.
- const splitActivePane = (pane: Pane, direction: "row" | "column") => {
- updateActiveTab((tab) => {
- const [layout, inserted] = splitPaneOnce(
- tab.layout,
- tab.focusedPaneId,
- pane,
- direction,
- );
- return inserted ? { ...tab, layout, focusedPaneId: pane.id } : tab;
- });
- };
- // Focus a pane within the active tab (where the split buttons act). No-op
- // unless the pane is in that tab.
- const setFocusedPane = (paneId: string) => {
- updateActiveTab((tab) =>
- splitPaneIds(tab.layout).includes(paneId)
- ? { ...tab, focusedPaneId: paneId }
- : tab,
- );
- };
- // Close a pane within the active tab, collapsing its split. Closing the last
- // pane of a non-chat tab closes the whole tab; the chat pane is permanent
- // (D6) — closing it is a no-op even when the chat tab is split.
- const closePane = (paneId: string) => {
- const tab = activeAgentTab();
- if (!tab) return;
- // The chat pane is permanent: it can't be pruned from the chat tab,
- // whether that tab is a lone leaf or a split. (The UI hides its close
- // button; this guards the public action too.)
- if (tab.id === CHAT_TAB_ID && paneId === CHAT_TAB_ID) return;
- const pruned = prunePane(tab.layout, paneId);
- if (!pruned) {
- // The tab has no panes left — close it (or no-op for the chat tab).
- closeTab(tab.id);
- return;
- }
- const focusedGone = !splitPaneIds(pruned).includes(tab.focusedPaneId);
- setTabs((prev) =>
- prev.map((t) =>
- t.id === tab.id
- ? {
- ...t,
- layout: pruned,
- focusedPaneId: focusedGone
- ? splitPaneIds(pruned)[0]
- : t.focusedPaneId,
- }
- : t,
- ),
- );
- };
// The last refused Stop, or undefined when the last attempt was not refused — the
// reactive hole the log panel RENDERS, the shape `askError` gives the ask block.
// Without it a refused Stop is observably identical to a successful one. Cleared at
@@ -1737,7 +1377,7 @@ export function createAppStore(options: AppStoreOptions): AppStore {
// Stop is idempotent server-side, so a retry after a refusal is safe.
const stopAgent = async (): Promise => {
setStopError(undefined);
- const session = agentSession();
+ const session = focused.agentSession();
if (!session) return;
// A fixture-sourced session's id was never minted by a server. Issuing Stop for it
// is worse than nothing: the server's unknown-session path is idempotent-success,
@@ -1869,7 +1509,9 @@ export function createAppStore(options: AppStoreOptions): AppStore {
// ── Right sidebar actions (T6) ──
const setActiveRepo = (repoId: string) => {
- if (agentRepos().some((r) => r.id === repoId)) setActiveRepoId(repoId);
+ if (agentRepos().some((r) => r.id === repoId)) {
+ focused.setActiveRepoId(repoId);
+ }
};
// ── Pins (Record A §T2/T3; unreachable-pin amendment RIG-1645) ──
@@ -1971,7 +1613,7 @@ export function createAppStore(options: AppStoreOptions): AppStore {
agentById,
rightTabGroups,
agentRepos,
- activeRepoId,
+ activeRepoId: focused.activeRepoId,
activeRepo,
setActiveRepo,
setActiveBranch,
@@ -1989,7 +1631,7 @@ export function createAppStore(options: AppStoreOptions): AppStore {
selectedTopicId,
selectedTopic,
openTopic,
- workspaceChannel,
+ focusedView,
joinChannel,
toggleSubscribe,
answerAsk,
@@ -1998,17 +1640,8 @@ export function createAppStore(options: AppStoreOptions): AppStore {
isAskSubmitted,
askError,
postMessage,
- agentSession,
- agentTabs,
- activeAgentTabId,
- activeAgentTab,
- setActiveAgentTab,
- openTab,
+ agentSessionById,
newTerminalPane,
- closeTab,
- splitActivePane,
- setFocusedPane,
- closePane,
stopAgent,
stopError,
logOpen,
diff --git a/apps/ui/src/stub-data.ts b/apps/ui/src/stub-data.ts
index 309bf58d0..cfeec3c21 100644
--- a/apps/ui/src/stub-data.ts
+++ b/apps/ui/src/stub-data.ts
@@ -354,7 +354,7 @@ type AgentLifecycle = AgentState;
* unstarted agent); role/model/cwd are UI-only roster config, terminals is
* pure fixture (no terminal stream in the MVP). The typed OMP session trace is
* NOT here — it is a separate type (`AgentSession`, session-events.ts) read by
- * account id via `store.agentSession()`, folded and rendered by Compass. */
+ * account id via a view's `agentSession()`, folded and rendered by Compass. */
export interface Agent {
account: Account;
lifecycle?: AgentLifecycle;
diff --git a/apps/ui/src/view-scope.ts b/apps/ui/src/view-scope.ts
index fc0c7148b..87bb6372c 100644
--- a/apps/ui/src/view-scope.ts
+++ b/apps/ui/src/view-scope.ts
@@ -6,8 +6,19 @@ import {
createSignal,
useContext,
} from "solid-js";
+import {
+ type AgentTab,
+ CHAT_TAB_ID,
+ chatTab,
+ type Pane,
+ prunePane,
+ splitPaneIds,
+ splitPaneOnce,
+} from "./agent-tabs";
+import { firstChannelId } from "./comms";
import type { Channel, Topic } from "./comms-stub";
-import { type AppStore, firstChannelId } from "./store";
+import type { AgentSession } from "./session-events";
+import type { AppStore } from "./store";
import type { Agent } from "./stub-data";
import type { RouteMatch } from "./view-route";
import { parseRoute } from "./view-route";
@@ -20,6 +31,29 @@ export interface ViewScope {
topic: Accessor;
agent: Accessor;
navigate: (path: string) => void;
+ /** The view agent's home DM: the agent workspace's chat pane. */
+ workspaceChannel: Accessor;
+ /** The view agent's session trace, or undefined off an agent route. */
+ agentSession: Accessor;
+ /** The workspace tabs, chat first; empty until the view's agent is set up. */
+ agentTabs: Accessor;
+ activeAgentTabId: Accessor;
+ activeAgentTab: Accessor;
+ /** Show a tab; no-op for an unknown id. */
+ setActiveAgentTab: (tabId: string) => void;
+ /** Open `pane` as a full-screen tab and show it; an open pane id just refocuses. */
+ openTab: (pane: Pane) => void;
+ /** Close a tab, falling back to chat; the chat tab is permanent. */
+ closeTab: (tabId: string) => void;
+ /** Split the active tab's focused pane, placing and focusing `pane` beside it. */
+ splitFocused: (pane: Pane, direction: "row" | "column") => void;
+ /** Focus a pane of the active tab; no-op for a pane not in it. */
+ setFocusedPane: (paneId: string) => void;
+ /** Close a pane of the active tab; its last pane closes the tab. */
+ closePane: (paneId: string) => void;
+ /** The repo clone picked in this view's workspace, or null. */
+ activeRepoId: Accessor;
+ setActiveRepoId: (repoId: string) => void;
}
export const ViewContext: Context = createContext<
@@ -32,9 +66,19 @@ export function useView(): ViewScope {
throw new Error("useView must be used within a ViewContext provider");
return view;
}
+/** The window-wide data a view resolves its route against. A subset of the
+ * store, so the store can build its own focused view while it is assembled. */
+export type ViewScopeStore = Pick<
+ AppStore,
+ | "channels"
+ | "topics"
+ | "agentById"
+ | "agentSessionById"
+ | "firstSnapshotArrived"
+>;
export function createViewScope(
- store: AppStore,
+ store: ViewScopeStore,
id: string,
initialPath: string,
): ViewScope {
@@ -50,15 +94,131 @@ export function createViewScope(
if (match.view !== "topic") return undefined;
return store.topics().find((item) => item.id === match.topicId);
});
- const agent = createMemo(() => {
+ const agentId = createMemo(() => {
const match = route();
- if (match.view !== "agent") return undefined;
- return store.agentById(match.agentId);
+ return match.view === "agent" ? match.agentId : null;
+ });
+ const agent = createMemo(() => {
+ const current = agentId();
+ return current ? store.agentById(current) : undefined;
+ });
+ // Keyed on the agent it was set up for: leaving and re-entering the same agent
+ // keeps the tabs, a different agent starts fresh (the old applyAgentRoute guard).
+ const [workspace, setWorkspace] = createSignal((prev) => {
+ const current = agentId();
+ if (prev && (current === null || prev.agentId === current)) return prev;
+ return freshWorkspace(current);
});
const navigate = (nextPath: string): void => {
setPath(nextPath);
};
+ const workspaceChannel = createMemo(() => {
+ const home = agent()?.account.homeChannelId;
+ return home ? store.channels().find((item) => item.id === home) : undefined;
+ });
+ const agentSession = createMemo(() => {
+ const current = agentId();
+ return current ? store.agentSessionById(current) : undefined;
+ });
+ const agentTabs = createMemo(() =>
+ agentId() ? workspace().tabs : [],
+ );
+ const activeAgentTabId = createMemo(() =>
+ agentId() ? workspace().activeTabId : null,
+ );
+ const activeAgentTab = createMemo(() =>
+ agentTabs().find((tab) => tab.id === activeAgentTabId()),
+ );
+ const activeRepoId = (): string | null => workspace().repoId;
+
+ const setActiveAgentTab = (tabId: string): void => {
+ if (!agentId()) return;
+ setWorkspace((ws) =>
+ ws.tabs.some((tab) => tab.id === tabId)
+ ? { ...ws, activeTabId: tabId }
+ : ws,
+ );
+ };
+ const updateActiveTab = (fn: (tab: AgentTab) => AgentTab): void => {
+ setWorkspace((ws) =>
+ ws.activeTabId
+ ? {
+ ...ws,
+ tabs: ws.tabs.map((tab) =>
+ tab.id === ws.activeTabId ? fn(tab) : tab,
+ ),
+ }
+ : ws,
+ );
+ };
+ const openTab = (pane: Pane): void => {
+ if (!agentId()) return;
+ setWorkspace((ws) => ({
+ ...ws,
+ tabs: ws.tabs.some((tab) => tab.id === pane.id)
+ ? ws.tabs
+ : [
+ ...ws.tabs,
+ {
+ id: pane.id,
+ title: pane.title,
+ layout: { kind: "leaf", pane },
+ focusedPaneId: pane.id,
+ },
+ ],
+ activeTabId: pane.id,
+ }));
+ };
+ const closeTab = (tabId: string): void => {
+ setWorkspace((ws) => withoutTab(ws, tabId));
+ };
+ const splitFocused = (pane: Pane, direction: "row" | "column"): void => {
+ updateActiveTab((tab) => {
+ const [layout, inserted] = splitPaneOnce(
+ tab.layout,
+ tab.focusedPaneId,
+ pane,
+ direction,
+ );
+ return inserted ? { ...tab, layout, focusedPaneId: pane.id } : tab;
+ });
+ };
+ const setFocusedPane = (paneId: string): void => {
+ updateActiveTab((tab) =>
+ splitPaneIds(tab.layout).includes(paneId)
+ ? { ...tab, focusedPaneId: paneId }
+ : tab,
+ );
+ };
+ const closePane = (paneId: string): void => {
+ setWorkspace((ws) => {
+ const tab = ws.tabs.find((item) => item.id === ws.activeTabId);
+ // The chat pane is permanent, whether its tab is a lone leaf or split.
+ if (!tab || (tab.id === CHAT_TAB_ID && paneId === CHAT_TAB_ID)) return ws;
+ const pruned = prunePane(tab.layout, paneId);
+ if (!pruned) return withoutTab(ws, tab.id);
+ const remaining = splitPaneIds(pruned);
+ return {
+ ...ws,
+ tabs: ws.tabs.map((item) =>
+ item.id === tab.id
+ ? {
+ ...item,
+ layout: pruned,
+ focusedPaneId: remaining.includes(item.focusedPaneId)
+ ? item.focusedPaneId
+ : (remaining[0] ?? item.focusedPaneId),
+ }
+ : item,
+ ),
+ };
+ });
+ };
+ const setActiveRepoId = (repoId: string): void => {
+ setWorkspace((ws) => ({ ...ws, repoId }));
+ };
+
// Pending-aware: an unknown id is held until the first snapshot, then this
// view (only) falls back, mirroring the store's applyChannelRoute.
createEffect(
@@ -92,5 +252,54 @@ export function createViewScope(
},
);
- return { id, path, route, channel, topic, agent, navigate };
+ return {
+ id,
+ path,
+ route,
+ channel,
+ topic,
+ agent,
+ navigate,
+ workspaceChannel,
+ agentSession,
+ agentTabs,
+ activeAgentTabId,
+ activeAgentTab,
+ setActiveAgentTab,
+ openTab,
+ closeTab,
+ splitFocused,
+ setFocusedPane,
+ closePane,
+ activeRepoId,
+ setActiveRepoId,
+ };
+}
+
+interface AgentWorkspace {
+ agentId: string | null;
+ tabs: AgentTab[];
+ activeTabId: string | null;
+ repoId: string | null;
+}
+
+function freshWorkspace(agentId: string | null): AgentWorkspace {
+ return agentId
+ ? {
+ agentId,
+ tabs: [chatTab()],
+ activeTabId: CHAT_TAB_ID,
+ repoId: `${agentId}-repo`,
+ }
+ : { agentId: null, tabs: [], activeTabId: null, repoId: null };
+}
+
+// Focus falls back to the chat tab, which itself can never close.
+function withoutTab(ws: AgentWorkspace, tabId: string): AgentWorkspace {
+ if (tabId === CHAT_TAB_ID) return ws;
+ return {
+ ...ws,
+ tabs: ws.tabs.filter((tab) => tab.id !== tabId),
+ activeTabId: ws.activeTabId === tabId ? CHAT_TAB_ID : ws.activeTabId,
+ };
}