Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -169,7 +170,9 @@ const App: Component<
<LeftSidebar />
</Show>

<main class="main">{props.children}</main>
<main class="main">
<ViewHost scope={store.focusedView()} />
</main>

<Show when={store.rightOpen()}>
<RightSidebar />
Expand Down
126 changes: 126 additions & 0 deletions apps/ui/src/agent-tabs.ts
Original file line number Diff line number Diff line change
@@ -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];
}
10 changes: 10 additions & 0 deletions apps/ui/src/comms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/components/AgentView.live.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -76,7 +77,9 @@ async function mountLive(fake: FakeComms): Promise<{
});
return (
<StoreContext value={store}>
<AgentView />
<ViewContext value={store.focusedView()}>
<AgentView />
</ViewContext>
</StoreContext>
);
});
Expand Down
36 changes: 20 additions & 16 deletions apps/ui/src/components/AgentView.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -67,7 +64,9 @@ function mountAgentView(): {
});
return (
<StoreContext value={store}>
<AgentView />
<ViewContext value={store.focusedView()}>
<AgentView />
</ViewContext>
</StoreContext>
);
});
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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");
Expand Down Expand Up @@ -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<HTMLButtonElement>(".av-tab-new");
Expand All @@ -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);
Expand Down
Loading
Loading