feat: add main features

Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

View File

@ -0,0 +1,105 @@
/**
* L4 — {@link LayoutGrid} wired through the real {@link DIProvider} with the
* in-memory {@link MockLayoutGateway} (+ {@link MockTerminalGateway} for the
* leaves' embedded {@link TerminalView}).
*
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
* so these tests assert the *structure* the grid renders (cells, no throw)
* rather than xterm's visual output. They stay robust whether or not xterm
* bailed.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
function renderGrid(layout: MockLayoutGateway, projectId = "p1") {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
</DIProvider>,
);
}
describe("LayoutGrid (with MockLayoutGateway)", () => {
it("renders a single-leaf layout without throwing", async () => {
const layout = new MockLayoutGateway();
expect(() => renderGrid(layout)).not.toThrow();
// The grid container always renders.
expect(screen.getByTestId("layout-grid")).toBeTruthy();
// Once loaded, the single leaf cell is shown.
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
});
it("renders N cells for a pre-split tree", async () => {
const layout = new MockLayoutGateway();
// Pre-split the project's tree into two leaves before rendering.
const initial = await layout.loadLayout("p1");
await layout.mutateLayout("p1", {
type: "split",
target: leaves(initial)[0].id,
direction: "row",
newLeaf: "b",
container: "c",
});
renderGrid(layout);
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2);
});
// The split container is present with its direction recorded.
const split = screen.getByTestId("layout-split");
expect(split.getAttribute("data-direction")).toBe("row");
});
it("stays graceful (no throw) even though xterm bails under jsdom", async () => {
const layout = new MockLayoutGateway();
// Render a 3-leaf tree: nested splits, each leaf hosts a TerminalView.
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: a,
direction: "row",
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "split",
target: "b",
direction: "column",
newLeaf: "d",
container: "e",
});
expect(() => renderGrid(layout)).not.toThrow();
await waitFor(() => {
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(3);
});
});
it("renders the loading/empty container when the layout gateway is absent", () => {
// A DI subset without the layout gateway → useLayout is defensive (null).
const gateways = { terminal: new MockTerminalGateway() } as unknown as Gateways;
expect(() =>
render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/c" />
</DIProvider>,
),
).not.toThrow();
expect(screen.getByTestId("layout-grid")).toBeTruthy();
});
});

View File

@ -0,0 +1,406 @@
/**
* Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested
* `Split` / `Grid` containers down to `Leaf` cells, each hosting a
* {@link TerminalView} (L3). Provides the spreadsheet-style interactions:
*
* - **resize**: drag the separator between two split children → recomputes the
* two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize;
* - **split**: per-cell buttons split a leaf into rows/columns → Split;
* - **merge**: when a split has > 1 child, a cell can collapse its parent split
* onto itself (spreadsheet-like cell merge) → Merge.
* - **agent**: each leaf cell has a dropdown to pin an agent; persisted via
* `setCellAgent` in the layout (#3).
*
* Pure presentation: all behaviour comes from {@link useLayout}, which speaks to
* the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure
* {@link normalizeWeights} function, kept out of the render for testability.
*/
import { useEffect, useRef, useState } from "react";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import { TerminalView } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
interface LayoutGridProps {
/** Project whose layout to render. */
projectId: string;
/** Working directory new terminals open in (the project root). */
cwd: string;
/** Active layout id; when provided the grid loads/mutates this layout. */
layoutId?: string;
}
export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
const vm = useLayout(projectId, layoutId);
if (!vm.layout) {
return (
<div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
{vm.error ? (
<p role="alert" style={{ color: "crimson" }}>
{vm.error}
</p>
) : (
<p>Loading layout</p>
)}
</div>
);
}
return (
<div
data-testid="layout-grid"
style={{ width: "100%", height: "100%", position: "relative" }}
>
{vm.error && (
<p role="alert" style={{ color: "crimson", margin: 0 }}>
{vm.error}
</p>
)}
<NodeView
node={vm.layout.root}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
/>
</div>
);
}
interface NodeViewProps {
node: LayoutNode;
cwd: string;
vm: LayoutViewModel;
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
}
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
<LeafView
id={node.node.id}
session={node.node.session ?? null}
agent={node.node.agent ?? null}
cwd={cwd}
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
/>
);
case "split":
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
case "grid":
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
}
}
interface LeafViewProps {
id: string;
session: string | null;
agent: string | null;
cwd: string;
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
}
function LeafView({ id, agent, cwd, vm, parentSplit, projectId }: LeafViewProps) {
const canMerge = parentSplit !== null && parentSplit.siblings > 1;
const { agent: agentGateway } = useGateways();
// Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
if (!agentGateway) return;
let cancelled = false;
agentGateway.listAgents(projectId).then((list) => {
if (!cancelled) setAgents(list);
}).catch(() => {/* ignore — dropdown stays empty */});
return () => { cancelled = true; };
}, [agentGateway, projectId]);
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
const terminalOpener = agentGateway && agentId
? (opts: Parameters<typeof agentGateway.launchAgent>[2], onData: (bytes: Uint8Array) => void) =>
agentGateway.launchAgent(projectId, agentId, opts, onData)
: undefined;
return (
<div
data-testid="layout-leaf"
data-node-id={id}
style={{
position: "relative",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
border: "1px solid #2a2a2a",
boxSizing: "border-box",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 2,
right: 2,
zIndex: 2,
display: "flex",
gap: 2,
alignItems: "center",
}}
>
{/* Agent selector */}
<select
aria-label={`agent selector ${id}`}
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
void vm.setCellAgent(id, val === "" ? null : val);
}}
style={{
fontSize: 11,
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 3,
padding: "1px 2px",
maxWidth: 100,
}}
>
<option value="">Plain</option>
{agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<button
type="button"
title="Split into columns"
aria-label={`split ${id} columns`}
onClick={() => void vm.split(id, "row")}
>
</button>
<button
type="button"
title="Split into rows"
aria-label={`split ${id} rows`}
onClick={() => void vm.split(id, "column")}
>
</button>
{canMerge && (
<button
type="button"
title="Merge: keep this cell, drop its siblings"
aria-label={`merge ${id}`}
onClick={() =>
void vm.merge(parentSplit.container, parentSplit.index)
}
>
</button>
)}
</div>
{/* Re-key terminal when the agent changes so xterm re-mounts with the right opener. */}
<TerminalView key={`${id}-${agentId ?? "plain"}`} cwd={cwd} open={terminalOpener} />
</div>
);
}
interface SplitViewProps {
split: Extract<LayoutNode, { type: "split" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
}
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
// Live drag preview: while a separator is dragged we override the rendered
// sizes locally (so the split moves under the cursor) and only commit the new
// weights to the backend on release (avoids a mutate round-trip per mousemove).
const [dragWeights, setDragWeights] = useState<number[] | null>(null);
const sizes = normalizeWeights(dragWeights ?? baseWeights);
return (
<div
ref={containerRef}
data-testid="layout-split"
data-direction={split.direction}
style={{
display: "flex",
flexDirection: isRow ? "row" : "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{split.children.map((child, i) => (
<div key={keyOf(child.node, i)} style={{ display: "contents" }}>
<div
style={{
flexBasis: `${sizes[i]}%`,
flexGrow: 0,
flexShrink: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView
node={child.node}
cwd={cwd}
vm={vm}
projectId={projectId}
parentSplit={{
container: split.id,
index: i,
siblings: split.children.length,
}}
/>
</div>
{i < split.children.length - 1 && (
<Separator
isRow={isRow}
container={containerRef}
onDragMove={(deltaFraction) =>
setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
}
onDragEnd={(deltaFraction) => {
const weights = resizeAdjacent(baseWeights, i, deltaFraction);
setDragWeights(null);
void vm.resize(split.id, weights);
}}
/>
)}
</div>
))}
</div>
);
}
interface SeparatorProps {
isRow: boolean;
container: React.RefObject<HTMLDivElement | null>;
/** Called continuously during the drag with the signed fraction of the container extent (live preview). */
onDragMove: (deltaFraction: number) => void;
/** Called on release with the final fraction (commit). */
onDragEnd: (deltaFraction: number) => void;
}
/**
* A draggable separator between two split children. It captures the pointer and
* reports the dragged distance (as a fraction of the container extent) **live**
* on every move so the split tracks the cursor, then once more on release so the
* parent can commit the new weights.
*/
function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) {
const startRef = useRef<number | null>(null);
function fraction(clientPos: number): number | null {
if (startRef.current === null) return null;
const rect = container.current?.getBoundingClientRect();
const extent = rect ? (isRow ? rect.width : rect.height) : 0;
if (extent <= 0) return null;
return (clientPos - startRef.current) / extent;
}
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
startRef.current = isRow ? e.clientX : e.clientY;
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (startRef.current === null) return;
const f = fraction(isRow ? e.clientX : e.clientY);
if (f !== null) onDragMove(f);
}
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
const f = fraction(isRow ? e.clientX : e.clientY);
startRef.current = null;
if (f !== null) onDragEnd(f);
}
return (
<div
role="separator"
aria-orientation={isRow ? "vertical" : "horizontal"}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
flex: "0 0 6px",
cursor: isRow ? "col-resize" : "row-resize",
background: "#3a3a3a",
touchAction: "none",
}}
/>
);
}
interface GridViewProps {
grid: Extract<LayoutNode, { type: "grid" }>["node"];
cwd: string;
vm: LayoutViewModel;
projectId: string;
}
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
const rows = normalizeWeights(grid.rowWeights)
.map((p) => `${p}fr`)
.join(" ");
return (
<div
data-testid="layout-grid-container"
style={{
display: "grid",
gridTemplateColumns: cols,
gridTemplateRows: rows,
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
{grid.cells.map((cell, i) => (
<div
key={keyOf(cell.node, i)}
style={{
gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
}}
>
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
</div>
))}
</div>
);
}
/** A stable-ish React key for a node (its id when it has one). */
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}

View File

@ -0,0 +1,136 @@
/**
* #4 — LayoutTabs / useLayouts: named-layout tab bar driven through the
* MockLayoutGateway. Covers: list, create, rename, delete, switch.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutTabs } from "./LayoutTabs";
function renderTabs(
layout: MockLayoutGateway,
projectId = "p1",
onActiveLayoutChange = vi.fn(),
) {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
agent: new MockAgentGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
</DIProvider>,
);
}
describe("LayoutTabs", () => {
it("renders the default 'Default' tab on first load", async () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByText("Default")).toBeTruthy();
});
});
it("shows a create (+) button", async () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
});
});
it("switching tabs reports the new active layout (id + kind) to the parent", async () => {
const layout = new MockLayoutGateway();
// Seed a second layout.
await layout.createLayout("p1", "Beta");
const onActiveLayoutChange = vi.fn();
renderTabs(layout, "p1", onActiveLayoutChange);
await waitFor(() => {
expect(screen.getByText("Beta")).toBeTruthy();
});
// Click the Beta tab.
fireEvent.click(screen.getByText("Beta"));
await waitFor(() => {
// Called with the full LayoutInfo (so the parent knows the kind too).
expect(onActiveLayoutChange).toHaveBeenCalledWith(
expect.objectContaining({ name: "Beta", kind: "terminal" }),
);
});
});
it("renaming a tab (double-click → type → blur) updates the name", async () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByText("Default")).toBeTruthy();
});
// Double-click to start rename.
fireEvent.dblClick(screen.getByText("Default"));
await waitFor(() => {
expect(screen.getByLabelText(/rename layout/i)).toBeTruthy();
});
const input = screen.getByLabelText(/rename layout/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: "My Layout" } });
fireEvent.blur(input);
await waitFor(() => {
expect(screen.getByText("My Layout")).toBeTruthy();
});
});
it("delete button is disabled when only one layout exists", async () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText(/delete layout/i)).toBeTruthy();
});
const closeBtn = screen.getByLabelText(/delete layout/i) as HTMLButtonElement;
expect(closeBtn.disabled).toBe(true);
});
it("creating a layout adds a tab", async () => {
const layout = new MockLayoutGateway();
// Directly create a second layout via the gateway (simulates the + flow).
await layout.createLayout("p1", "Extra");
renderTabs(layout);
await waitFor(() => {
expect(screen.getByText("Default")).toBeTruthy();
expect(screen.getByText("Extra")).toBeTruthy();
});
});
it("deleting a layout removes its tab (when 2 exist)", async () => {
const layout = new MockLayoutGateway();
await layout.createLayout("p1", "Second");
renderTabs(layout);
await waitFor(() => {
expect(screen.getByText("Second")).toBeTruthy();
});
const deleteBtns = screen.getAllByLabelText(/delete layout/i);
// Both tabs exist, pick the second (Second).
await act(async () => {
fireEvent.click(deleteBtns[1]);
});
await waitFor(() => {
expect(screen.queryByText("Second")).toBeNull();
});
});
});

View File

@ -0,0 +1,210 @@
/**
* `LayoutTabs` — named-layout tab bar rendered above the terminal grid (L4 / #4).
*
* - Shows one tab per named layout (reuses the shared `Tabs` component).
* - `+` button: offers a dropdown to create a "Terminal" or "Git graph" layout.
* - Double-click a tab label: in-place rename.
* - `×` button: closes the layout (disabled when it is the last one).
* - Switching tabs calls `setActiveLayout` via `useLayouts.setActive`.
* - Tabs for `gitGraph` layouts show a small "⎇" badge.
*
* Pure presentation; all state lives in the `useLayouts` hook.
*/
import { useEffect, useRef, useState } from "react";
import type { LayoutInfo } from "@/domain";
import { cn } from "@/shared";
import { useLayouts } from "./useLayouts";
interface LayoutTabsProps {
projectId: string;
/**
* Called whenever the active layout changes (and on initial load) with the
* full active {@link LayoutInfo} — so the parent knows both its id (to re-key
* the grid) **and its kind** (to switch between the terminal grid and the git
* graph view). This is the single source of truth: the parent must NOT keep a
* separate `useLayouts` instance, or its `kind` would go stale.
*/
onActiveLayoutChange: (info: LayoutInfo) => void;
}
export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) {
const vm = useLayouts(projectId);
// Propagate the active layout (id + kind) to the parent on every change.
useEffect(() => {
const info = vm.layouts.find((l) => l.id === vm.activeId);
if (info) onActiveLayoutChange(info);
}, [vm.activeId, vm.layouts, onActiveLayoutChange]);
// Track which tab is being renamed (null = none).
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const renameInputRef = useRef<HTMLInputElement | null>(null);
// Show/hide the create-kind dropdown.
const [showCreateMenu, setShowCreateMenu] = useState(false);
async function handleSelect(id: string) {
// The effect above propagates the new active layout (id + kind) to the parent.
await vm.setActive(id);
}
async function handleClose(id: string) {
if (vm.layouts.length <= 1) return;
await vm.deleteLayout(id);
// The hook updates activeId; the effect propagates it to the parent.
}
function startRename(id: string, currentName: string) {
setRenamingId(id);
setRenameValue(currentName);
// Focus the input on next tick once it's rendered.
setTimeout(() => renameInputRef.current?.focus(), 0);
}
async function commitRename() {
if (!renamingId) return;
const trimmed = renameValue.trim();
if (trimmed) await vm.rename(renamingId, trimmed);
setRenamingId(null);
setRenameValue("");
}
async function handleCreate(kind: "terminal" | "gitGraph") {
setShowCreateMenu(false);
const defaultName = kind === "gitGraph" ? "Git Graph" : "Layout";
const name = window.prompt("New layout name:", defaultName);
if (!name?.trim()) return;
const newId = await vm.create(name.trim(), kind);
if (newId) {
// Make it active; the effect propagates the new active layout (with its
// `kind`) to the parent, which then renders the right main view.
await vm.setActive(newId);
}
}
if (vm.layouts.length === 0) return null;
return (
<div
data-testid="layout-tabs"
className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1"
>
{vm.layouts.map((tab) => {
const isActive = tab.id === vm.activeId;
const isRenaming = tab.id === renamingId;
return (
<div
key={tab.id}
className={cn(
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
isActive
? "border-border-strong bg-raised"
: "border-transparent hover:bg-raised",
)}
>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename()}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename();
if (e.key === "Escape") {
setRenamingId(null);
setRenameValue("");
}
}}
aria-label={`rename layout ${tab.name}`}
className="w-24 rounded border border-border bg-base px-1 py-0.5 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary"
/>
) : (
<button
type="button"
role="tab"
aria-selected={isActive}
onClick={() => void handleSelect(tab.id)}
onDoubleClick={() => startRename(tab.id, tab.name)}
className={cn(
"flex items-center gap-1 px-2 py-1 text-sm focus-visible:outline-none",
isActive
? "font-semibold text-content"
: "text-muted hover:text-content",
)}
>
{tab.kind === "gitGraph" && (
<span
aria-label="git graph layout"
className="text-xs text-primary"
title="Git Graph"
>
</span>
)}
{tab.name}
</button>
)}
<button
type="button"
aria-label={`delete layout ${tab.name}`}
disabled={vm.layouts.length <= 1}
onClick={() => void handleClose(tab.id)}
className={cn(
"flex h-4 w-4 items-center justify-center rounded text-xs transition-opacity",
vm.layouts.length <= 1
? "cursor-not-allowed opacity-20"
: "opacity-60 hover:opacity-100 group-hover:opacity-100",
)}
>
×
</button>
</div>
);
})}
{/* Create new layout — dropdown to choose kind */}
<div className="relative">
<button
type="button"
aria-label="create layout"
onClick={() => setShowCreateMenu((v) => !v)}
className="flex h-6 w-6 items-center justify-center rounded border border-transparent text-sm text-muted transition-colors hover:border-border hover:text-content"
>
+
</button>
{showCreateMenu && (
<div
role="menu"
className="absolute left-0 top-full z-20 mt-1 flex min-w-max flex-col rounded-md border border-border bg-surface shadow-lg"
>
<button
type="button"
role="menuitem"
aria-label="create terminal layout"
onClick={() => void handleCreate("terminal")}
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
Terminal
</button>
<button
type="button"
role="menuitem"
aria-label="create git graph layout"
onClick={() => void handleCreate("gitGraph")}
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
Git graph
</button>
</div>
)}
</div>
{vm.error && (
<span className="ml-2 text-xs text-danger" role="alert">
{vm.error}
</span>
)}
</div>
);
}

View File

@ -0,0 +1,15 @@
/** Layout feature (L4): recursive spreadsheet-like terminal grid. */
export { LayoutGrid } from "./LayoutGrid";
export { useLayout, type LayoutViewModel } from "./useLayout";
export { useLayouts, type LayoutsViewModel } from "./useLayouts";
export { LayoutTabs } from "./LayoutTabs";
export {
applyOperation,
leaves,
newId,
normalizeWeights,
resizeAdjacent,
singleLeafTree,
splitOp,
} from "./layout";

View File

@ -0,0 +1,188 @@
/**
* L4 — unit tests for the pure, framework-free layout logic
* (`@/features/layout/layout.ts`): `normalizeWeights`, `resizeAdjacent`,
* `applyOperation`, plus `singleLeafTree`/`leaves`/`splitOp`.
*
* These mirror the pure domain operations and carry no DOM/Tauri dependency.
*/
import { describe, it, expect } from "vitest";
import type { LayoutTree } from "@/domain";
import {
applyOperation,
leaves,
normalizeWeights,
resizeAdjacent,
singleLeafTree,
splitOp,
} from "./layout";
const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0);
describe("normalizeWeights", () => {
it("returns [] for an empty list", () => {
expect(normalizeWeights([])).toEqual([]);
});
it("sums to ~100 for equal weights", () => {
const out = normalizeWeights([1, 1, 1, 1]);
expect(sum(out)).toBeCloseTo(100, 5);
expect(out).toEqual([25, 25, 25, 25]);
});
it("preserves proportions for unequal weights", () => {
const out = normalizeWeights([3, 1]);
expect(sum(out)).toBeCloseTo(100, 5);
expect(out[0]).toBeCloseTo(75, 5);
expect(out[1]).toBeCloseTo(25, 5);
});
it("floors non-positive / NaN weights to an epsilon (still renders)", () => {
const out = normalizeWeights([0, -5, Number.NaN, 1]);
expect(sum(out)).toBeCloseTo(100, 5);
// The last (only real) track dominates; the floored ones stay > 0.
expect(out.every((p) => p > 0)).toBe(true);
expect(out[3]).toBeGreaterThan(99);
});
it("falls back to even shares when every weight is degenerate", () => {
const out = normalizeWeights([0, 0]);
expect(sum(out)).toBeCloseTo(100, 5);
expect(out[0]).toBeCloseTo(out[1], 5);
});
});
describe("resizeAdjacent", () => {
it("transfers extent between two adjacent tracks", () => {
const out = resizeAdjacent([50, 50], 0, 0.1); // grow track 0 by 10%
expect(out[0]).toBeCloseTo(60, 5);
expect(out[1]).toBeCloseTo(40, 5);
expect(sum(out)).toBeCloseTo(100, 5); // total preserved
});
it("only affects the two adjacent tracks, leaving others untouched", () => {
const out = resizeAdjacent([40, 40, 20], 0, 0.1);
expect(out[2]).toBe(20);
});
it("returns a fresh array (does not mutate the input)", () => {
const input = [50, 50];
const out = resizeAdjacent(input, 0, 0.1);
expect(out).not.toBe(input);
expect(input).toEqual([50, 50]);
});
it("refuses a drag that would crush a track below the minimum", () => {
const input = [50, 50];
// A near-total drag would push track 1 below the 5% floor → no change.
const out = resizeAdjacent(input, 0, 0.49);
expect(out).toEqual([50, 50]);
});
it("is a no-op for an out-of-range index", () => {
expect(resizeAdjacent([50, 50], 1, 0.1)).toEqual([50, 50]);
expect(resizeAdjacent([50, 50], -1, 0.1)).toEqual([50, 50]);
});
});
describe("singleLeafTree / leaves", () => {
it("builds a single empty leaf with the given id", () => {
const tree = singleLeafTree("abc");
expect(tree.root.type).toBe("leaf");
expect(leaves(tree)).toEqual([{ id: "abc", session: null }]);
});
});
describe("applyOperation", () => {
const base = (): LayoutTree => singleLeafTree("a");
it("split increases the leaf count", () => {
const before = base();
const op = { ...splitOp("a", "row"), newLeaf: "b", container: "c" } as const;
const after = applyOperation(before, op);
expect(leaves(before)).toHaveLength(1); // original untouched (immutable)
expect(leaves(after)).toHaveLength(2);
expect(after.root.type).toBe("split");
});
it("resize changes the child weights", () => {
const split = applyOperation(base(), {
type: "split",
target: "a",
direction: "row",
newLeaf: "b",
container: "c",
});
const resized = applyOperation(split, {
type: "resize",
container: "c",
weights: [3, 1],
});
expect(resized.root.type).toBe("split");
if (resized.root.type === "split") {
expect(resized.root.node.children.map((c) => c.weight)).toEqual([3, 1]);
}
});
it("setSession attaches the session id to the leaf", () => {
const after = applyOperation(base(), {
type: "setSession",
target: "a",
session: "sess-1",
});
expect(leaves(after)[0]).toEqual({ id: "a", session: "sess-1" });
});
it("setSession with null clears the session", () => {
const attached = applyOperation(base(), {
type: "setSession",
target: "a",
session: "sess-1",
});
const cleared = applyOperation(attached, {
type: "setSession",
target: "a",
session: null,
});
expect(leaves(cleared)[0].session ?? null).toBeNull();
});
it("merge collapses a split back to a kept child", () => {
const split = applyOperation(base(), {
type: "split",
target: "a",
direction: "row",
newLeaf: "b",
container: "c",
});
const merged = applyOperation(split, {
type: "merge",
container: "c",
keepIndex: 1,
});
expect(merged.root.type).toBe("leaf");
expect(leaves(merged)).toEqual([{ id: "b", session: null }]);
});
it("throws a NOT_FOUND-like error on an unknown target", () => {
expect(() =>
applyOperation(base(), {
type: "setSession",
target: "missing",
session: null,
}),
).toThrowError(/not found/);
});
});
describe("splitOp", () => {
it("builds a split op with fresh ids", () => {
const op = splitOp("a", "column");
expect(op).toMatchObject({ type: "split", target: "a", direction: "column" });
if (op.type === "split") {
expect(typeof op.newLeaf).toBe("string");
expect(typeof op.container).toBe("string");
expect(op.newLeaf).not.toEqual(op.container);
}
});
});

View File

@ -0,0 +1,279 @@
/**
* Pure, framework-free layout logic for the spreadsheet-like terminal grid
* (L4). No React, no Tauri here — these functions are the TS mirror of the
* domain's pure `LayoutTree` operations (ARCHITECTURE §1.3, §7), so they are
* trivially unit-testable (Vitest) and reusable by the mock gateway.
*
* The size calculation (`normalizeWeights`) is the load-bearing pure function
* the spec calls out: it turns a list of relative weights into rendering
* percentages, independent of any DOM.
*/
import type {
Direction,
LayoutNode,
LayoutOperation,
LayoutTree,
LeafCell,
SplitContainer,
WeightedChild,
} from "@/domain";
/** A simple UUID v4 generator (mock/UI side; the backend mints its own ids). */
export function newId(): string {
// crypto.randomUUID is available in modern browsers and jsdom.
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
// Fallback (deterministic enough for non-crypto UI ids).
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/** A single empty leaf tree — the default layout (one cell, no session). */
export function singleLeafTree(id: string = newId()): LayoutTree {
return { root: { type: "leaf", node: { id, session: null } } };
}
/**
* Normalises relative weights into rendering percentages that sum to ~100.
*
* Pure and total: an empty list yields `[]`; non-positive/NaN weights are
* floored to a tiny epsilon so a malformed tree still renders rather than
* collapsing a track to zero. This is the function the grid component uses to
* size each track (`grid-template-*` / flex-basis).
*/
export function normalizeWeights(weights: number[]): number[] {
if (weights.length === 0) return [];
const EPS = 1e-6;
const safe = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : EPS));
const total = safe.reduce((a, b) => a + b, 0);
if (total <= 0) {
const even = 100 / safe.length;
return safe.map(() => even);
}
return safe.map((w) => (w / total) * 100);
}
/**
* Computes new weights for a split after dragging the separator between
* children `index` and `index + 1` by `deltaFraction` (a signed fraction of the
* container's full extent, e.g. `+0.05` = grow the left/top child by 5%).
*
* Pure: only the two adjacent tracks are affected; both stay strictly positive
* (clamped to a small minimum). Returns a fresh array.
*/
export function resizeAdjacent(
weights: number[],
index: number,
deltaFraction: number,
): number[] {
const next = [...weights];
if (index < 0 || index + 1 >= next.length) return next;
const total = next.reduce((a, b) => a + b, 0);
const delta = deltaFraction * total;
const MIN = total * 0.05;
const a = next[index] + delta;
const b = next[index + 1] - delta;
if (a < MIN || b < MIN) return next; // refuse to crush a track
next[index] = a;
next[index + 1] = b;
return next;
}
/** Recursively rebuilds a node, applying `f` (post-order, like the domain). */
function mapNode(node: LayoutNode, f: (n: LayoutNode) => LayoutNode): LayoutNode {
let rebuilt: LayoutNode;
switch (node.type) {
case "leaf":
rebuilt = node;
break;
case "split":
rebuilt = {
type: "split",
node: {
...node.node,
children: node.node.children.map((c) => ({
node: mapNode(c.node, f),
weight: c.weight,
})),
},
};
break;
case "grid":
rebuilt = {
type: "grid",
node: {
...node.node,
cells: node.node.cells.map((c) => ({
...c,
node: mapNode(c.node, f),
})),
},
};
break;
}
return f(rebuilt);
}
/** Finds the session hosted by leaf `id`, or `undefined` if no such leaf. */
function sessionInLeaf(node: LayoutNode, id: string): string | null | undefined {
let found: string | null | undefined;
mapNode(node, (n) => {
if (n.type === "leaf" && n.node.id === id) found = n.node.session ?? null;
return n;
});
return found;
}
/**
* Applies a {@link LayoutOperation} to a tree, returning a **new** tree (pure,
* immutable). Mirrors the domain operations so the mock gateway behaves like the
* real backend offline. Throws on an invalid operation (unknown node, etc.).
*/
export function applyOperation(
tree: LayoutTree,
op: LayoutOperation,
): LayoutTree {
switch (op.type) {
case "split": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.target) {
found = true;
const newLeaf: LeafCell = { id: op.newLeaf, session: null };
const container: SplitContainer = {
id: op.container,
direction: op.direction,
children: [
{ node: n, weight: 1 },
{ node: { type: "leaf", node: newLeaf }, weight: 1 },
],
};
return { type: "split", node: container };
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
case "merge": {
let touched = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "split" && n.node.id === op.container) {
const keep = n.node.children[op.keepIndex];
if (!keep) throw invalid(`merge keepIndex ${op.keepIndex} out of range`);
touched = true;
return keep.node;
}
return n;
});
if (!touched) throw notFound(op.container);
return { root };
}
case "resize": {
let touched = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "split" && n.node.id === op.container) {
if (n.node.children.length !== op.weights.length) {
throw invalid("resize weight count mismatch");
}
touched = true;
const children: WeightedChild[] = n.node.children.map((c, i) => ({
node: c.node,
weight: op.weights[i],
}));
return { type: "split", node: { ...n.node, children } };
}
return n;
});
if (!touched) throw notFound(op.container);
return { root };
}
case "move": {
const session = sessionInLeaf(tree.root, op.from);
if (session === undefined) throw notFound(op.from);
if (session === null) throw invalid("source cell has no session");
const target = sessionInLeaf(tree.root, op.to);
if (target === undefined) throw notFound(op.to);
if (target !== null) throw invalid("target cell is occupied");
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.from) {
return { type: "leaf", node: { id: n.node.id, session: null } };
}
if (n.type === "leaf" && n.node.id === op.to) {
return { type: "leaf", node: { id: n.node.id, session } };
}
return n;
});
return { root };
}
case "setSession": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.target) {
found = true;
return {
type: "leaf",
node: { ...n.node, session: op.session ?? null },
};
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
case "setCellAgent": {
let found = false;
const root = mapNode(tree.root, (n) => {
if (n.type === "leaf" && n.node.id === op.target) {
found = true;
const { agent: _a, ...rest } = n.node;
const updated = op.agent !== null
? { ...rest, agent: op.agent }
: rest;
return { type: "leaf", node: updated };
}
return n;
});
if (!found) throw notFound(op.target);
return { root };
}
}
}
/** Collects all leaf cells of a tree in document order. */
export function leaves(tree: LayoutTree): LeafCell[] {
const out: LeafCell[] = [];
mapNode(tree.root, (n) => {
if (n.type === "leaf") out.push(n.node);
return n;
});
return out;
}
/** Convenience: builds a `split` operation splitting `target` in `direction`. */
export function splitOp(target: string, direction: Direction): LayoutOperation {
return {
type: "split",
target,
direction,
newLeaf: newId(),
container: newId(),
};
}
function notFound(id: string): GatewayLikeError {
return { code: "NOT_FOUND", message: `layout node ${id} not found` };
}
function invalid(msg: string): GatewayLikeError {
return { code: "INVALID", message: msg };
}
interface GatewayLikeError {
code: string;
message: string;
}

View File

@ -0,0 +1,102 @@
/**
* Tests for the LayoutTabs + git-graph layout creation flow.
*
* Covers:
* - The "+" button shows a create-kind dropdown.
* - Choosing "Git graph" from the dropdown creates a layout with kind=gitGraph.
* - A ⎇ badge is visible on git-graph layout tabs.
* - Creating a terminal layout does NOT show the ⎇ badge.
* - The mock `createLayout` correctly stores the kind.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockLayoutGateway, MockAgentGateway, MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutTabs } from "./LayoutTabs";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTabs(
layout: MockLayoutGateway,
projectId = "p1",
onActiveLayoutChange = vi.fn(),
) {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
agent: new MockAgentGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutTabs projectId={projectId} onActiveLayoutChange={onActiveLayoutChange} />
</DIProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("LayoutTabs — git graph kind", () => {
it("+ button shows a dropdown menu with Terminal and Git graph options", async () => {
const layout = new MockLayoutGateway();
renderTabs(layout);
await waitFor(() => {
expect(screen.getByLabelText("create layout")).toBeTruthy();
});
fireEvent.click(screen.getByLabelText("create layout"));
await waitFor(() => {
expect(screen.getByLabelText("create terminal layout")).toBeTruthy();
expect(screen.getByLabelText("create git graph layout")).toBeTruthy();
});
});
it("MockLayoutGateway.createLayout stores kind=gitGraph correctly", async () => {
const layout = new MockLayoutGateway();
const { layoutId } = await layout.createLayout("p1", "My Graph", "gitGraph");
const { layouts } = await layout.listLayouts("p1");
const created = layouts.find((l) => l.id === layoutId);
expect(created).toBeDefined();
expect(created!.kind).toBe("gitGraph");
});
it("MockLayoutGateway.createLayout defaults to kind=terminal", async () => {
const layout = new MockLayoutGateway();
const { layoutId } = await layout.createLayout("p1", "My Terminal");
const { layouts } = await layout.listLayouts("p1");
const created = layouts.find((l) => l.id === layoutId);
expect(created!.kind).toBe("terminal");
});
it("git graph tab shows the ⎇ badge", async () => {
const layout = new MockLayoutGateway();
// Seed a git-graph layout directly.
await layout.createLayout("p1", "Graph Tab", "gitGraph");
renderTabs(layout);
await waitFor(() => {
// The aria-label "git graph layout" should be present.
expect(screen.getByLabelText("git graph layout")).toBeTruthy();
});
});
it("terminal tab does NOT show the ⎇ badge", async () => {
const layout = new MockLayoutGateway();
// Only the default "terminal" layout.
renderTabs(layout);
await waitFor(() => {
expect(screen.getByText("Default")).toBeTruthy();
});
expect(screen.queryByLabelText("git graph layout")).toBeNull();
});
});

View File

@ -0,0 +1,150 @@
/**
* #3 — Agent dropdown per cell: setCellAgent persists in the layout, and the
* terminal re-mounts with the agent opener; "Plain" reverts to a plain terminal.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import {
MockLayoutGateway,
MockAgentGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { LayoutGrid } from "./LayoutGrid";
import { leaves } from "./layout";
function renderGrid(
layout: MockLayoutGateway,
agent: MockAgentGateway,
projectId = "p1",
) {
const gateways = {
layout,
terminal: new MockTerminalGateway(),
agent,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
</DIProvider>,
);
}
describe("setCellAgent (agent dropdown per cell)", () => {
it("renders an agent selector on each leaf cell", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
renderGrid(layout, agent);
await waitFor(() => {
expect(screen.getAllByRole("combobox")).toHaveLength(1);
});
});
it("selecting an agent calls setCellAgent via the layout gateway", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
// Create an agent in the project.
const a = await agent.createAgent("p1", {
name: "MyAgent",
profileId: "prof-1",
});
renderGrid(layout, agent);
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: a.id } });
// The layout should persist the agent on the leaf.
const initial = await layout.loadLayout("p1");
const leafId = leaves(initial)[0].id;
await waitFor(async () => {
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
// The mutation should have persisted.
expect(leaf.id).toBe(leafId);
// Since mutateLayout is async, give it a tick.
});
});
it("MockLayoutGateway setCellAgent persists and clears correctly", async () => {
const layout = new MockLayoutGateway();
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
// Set agent.
const after = await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: "agent-42",
});
expect(leaves(after)[0].agent).toBe("agent-42");
// Clear agent (null).
const cleared = await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: null,
});
expect(leaves(cleared)[0].agent).toBeUndefined();
});
it("selecting Plain (empty) clears the agent via mutateLayout", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", {
name: "MyAgent",
profileId: "prof-1",
});
// Pre-set agent on the leaf.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: a.id,
});
renderGrid(layout, agent);
await waitFor(() => {
const sel = screen.getByRole("combobox") as HTMLSelectElement;
// The select should show the agent (or Plain if the component loaded before the mutation).
expect(sel).toBeTruthy();
});
const select = screen.getByRole("combobox") as HTMLSelectElement;
// Switch back to Plain.
fireEvent.change(select, { target: { value: "" } });
// After clearing, the layout should have no agent on the leaf.
await waitFor(async () => {
const fresh = await layout.loadLayout("p1");
const l = leaves(fresh)[0];
expect(l.agent === undefined || l.agent === null).toBe(true);
});
});
it("agent selector shows project agents as options", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
await agent.createAgent("p1", { name: "Agent Alpha", profileId: "p" });
await agent.createAgent("p1", { name: "Agent Beta", profileId: "p" });
renderGrid(layout, agent);
await waitFor(() => {
expect(screen.getByText("Agent Alpha")).toBeTruthy();
expect(screen.getByText("Agent Beta")).toBeTruthy();
});
});
});

View File

@ -0,0 +1,131 @@
/**
* `useLayout` — view-model hook for the terminal layout grid (L4).
*
* Owns the loaded {@link LayoutTree} for a project and exposes the actions the
* grid UI triggers (split/merge/resize/move). It consumes the
* {@link LayoutGateway} exclusively — never `invoke()` — so the component layer
* stays testable with a mock gateway (ARCHITECTURE §1.3). Each action calls the
* backend, which persists `.ideai/layout.json` and returns the new tree.
*/
import { useCallback, useEffect, useState } from "react";
import type {
Direction,
GatewayError,
LayoutOperation,
LayoutTree,
} from "@/domain";
import { useGateways } from "@/app/di";
import { splitOp } from "./layout";
/** What the layout grid UI needs from this hook. */
export interface LayoutViewModel {
/** The current layout tree, or `null` until loaded. */
layout: LayoutTree | null;
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Splits a leaf into two cells in the given direction. */
split: (target: string, direction: Direction) => Promise<void>;
/** Collapses a split container back to the child at `keepIndex`. */
merge: (container: string, keepIndex: number) => Promise<void>;
/** Reassigns a split's child weights (separator drag). */
resize: (container: string, weights: number[]) => Promise<void>;
/** Moves the session from one cell to another (empty) cell. */
move: (from: string, to: string) => Promise<void>;
/** Records (or clears) the session hosted by a cell. */
setSession: (target: string, session: string | null) => Promise<void>;
/** Pins or clears an agent on a cell (persisted in layout). */
setCellAgent: (target: string, agent: string | null) => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useLayout(
projectId: string | null,
layoutId?: string,
): LayoutViewModel {
const { layout: gateway } = useGateways();
const [layout, setLayout] = useState<LayoutTree | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
// No project, or a DI subset without the layout gateway (e.g. a focused
// test) → render nothing rather than crash the tab (mirrors TerminalView).
if (!projectId || !gateway) {
setLayout(null);
return;
}
let cancelled = false;
setBusy(true);
gateway
.loadLayout(projectId, layoutId)
.then((tree) => {
if (!cancelled) setLayout(tree);
})
.catch((e: unknown) => {
if (!cancelled) setError(describe(e));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [gateway, projectId, layoutId]);
const mutate = useCallback(
async (operation: LayoutOperation) => {
if (!projectId || !gateway) return;
setBusy(true);
setError(null);
try {
setLayout(await gateway.mutateLayout(projectId, operation, layoutId));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[gateway, projectId, layoutId],
);
const split = useCallback(
(target: string, direction: Direction) => mutate(splitOp(target, direction)),
[mutate],
);
const merge = useCallback(
(container: string, keepIndex: number) =>
mutate({ type: "merge", container, keepIndex }),
[mutate],
);
const resize = useCallback(
(container: string, weights: number[]) =>
mutate({ type: "resize", container, weights }),
[mutate],
);
const move = useCallback(
(from: string, to: string) => mutate({ type: "move", from, to }),
[mutate],
);
const setSession = useCallback(
(target: string, session: string | null) =>
mutate({ type: "setSession", target, session }),
[mutate],
);
const setCellAgent = useCallback(
(target: string, agent: string | null) =>
mutate({ type: "setCellAgent", target, agent }),
[mutate],
);
return { layout, error, busy, split, merge, resize, move, setSession, setCellAgent };
}

View File

@ -0,0 +1,153 @@
/**
* `useLayouts` — view-model hook for the named-layout tab bar (L4 / #4).
*
* Manages the list of named layouts for a project (list / create / rename /
* delete / set-active). Speaks exclusively to the {@link LayoutGateway} port.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, LayoutInfo, LayoutKind } from "@/domain";
import { useGateways } from "@/app/di";
export interface LayoutsViewModel {
/** Ordered list of named layouts. */
layouts: LayoutInfo[];
/** Currently active layout id. */
activeId: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Last error message, or `null`. */
error: string | null;
/** Switches the active layout (does NOT force a re-fetch here; the caller uses the returned activeId). */
setActive: (layoutId: string) => Promise<void>;
/** Creates a new layout with the given name and kind; resolves with the new layoutId. */
create: (name: string, kind?: LayoutKind) => Promise<string | null>;
/** Renames a layout. */
rename: (layoutId: string, name: string) => Promise<void>;
/** Deletes a layout; refuses if it is the last one. */
deleteLayout: (layoutId: string) => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useLayouts(projectId: string | null): LayoutsViewModel {
const { layout: gateway } = useGateways();
const [layouts, setLayouts] = useState<LayoutInfo[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load the list on mount and whenever projectId changes.
useEffect(() => {
if (!projectId || !gateway) {
setLayouts([]);
setActiveId(null);
return;
}
let cancelled = false;
setBusy(true);
gateway
.listLayouts(projectId)
.then(({ layouts: list, activeId: aid }) => {
if (!cancelled) {
setLayouts(list);
setActiveId(aid);
}
})
.catch((e: unknown) => {
if (!cancelled) setError(describe(e));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [gateway, projectId]);
const setActive = useCallback(
async (layoutId: string) => {
if (!projectId || !gateway) return;
setBusy(true);
setError(null);
try {
await gateway.setActiveLayout(projectId, layoutId);
setActiveId(layoutId);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[gateway, projectId],
);
const create = useCallback(
async (name: string, kind?: LayoutKind): Promise<string | null> => {
if (!projectId || !gateway) return null;
setBusy(true);
setError(null);
try {
const { layoutId } = await gateway.createLayout(projectId, name, kind);
const updated = await gateway.listLayouts(projectId);
setLayouts(updated.layouts);
return layoutId;
} catch (e) {
setError(describe(e));
return null;
} finally {
setBusy(false);
}
},
[gateway, projectId],
);
const rename = useCallback(
async (layoutId: string, name: string) => {
if (!projectId || !gateway) return;
setBusy(true);
setError(null);
try {
await gateway.renameLayout(projectId, layoutId, name);
setLayouts((prev) =>
prev.map((l) => (l.id === layoutId ? { ...l, name } : l)),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[gateway, projectId],
);
const deleteLayout = useCallback(
async (layoutId: string) => {
if (!projectId || !gateway) return;
if (layouts.length <= 1) {
setError("Cannot delete the last layout.");
return;
}
setBusy(true);
setError(null);
try {
const { activeId: newActiveId } = await gateway.deleteLayout(projectId, layoutId);
setLayouts((prev) => prev.filter((l) => l.id !== layoutId));
setActiveId(newActiveId);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[gateway, projectId, layouts.length],
);
return { layouts, activeId, busy, error, setActive, create, rename, deleteLayout };
}