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

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IdeA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

5805
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "idea-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.19.41",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^29.1.1",
"tailwindcss": "^4.3.0",
"typescript": "^5.6.3",
"vite": "^5.4.11",
"vitest": "^4.1.8"
}
}

View File

@ -0,0 +1,62 @@
/**
* Contract tests for `TauriAgentGateway`: they assert the exact `invoke()`
* payload shape, which the mock gateway (used by feature tests) does NOT
* exercise. This guards the class of "works in mock, broken in the real app"
* bugs — e.g. forgetting to nest `projectId` inside the command's `request` DTO.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args),
Channel: class {
onmessage: ((c: number[]) => void) | null = null;
},
}));
import { TauriAgentGateway } from "./agent";
describe("TauriAgentGateway invoke payloads", () => {
beforeEach(() => invoke.mockReset().mockResolvedValue({}));
it("create_agent nests projectId inside the request DTO", async () => {
await new TauriAgentGateway().createAgent("proj-1", {
name: "Backend",
profileId: "prof-9",
});
expect(invoke).toHaveBeenCalledWith("create_agent", {
request: {
projectId: "proj-1",
name: "Backend",
profileId: "prof-9",
initialContent: null,
},
});
});
it("update_agent_context wraps fields in the request DTO", async () => {
await new TauriAgentGateway().updateContext("proj-1", "agent-2", "# ctx");
expect(invoke).toHaveBeenCalledWith("update_agent_context", {
request: { projectId: "proj-1", agentId: "agent-2", content: "# ctx" },
});
});
it("list_agents / read / delete pass top-level args (no request wrapper)", async () => {
const gw = new TauriAgentGateway();
await gw.listAgents("p");
expect(invoke).toHaveBeenCalledWith("list_agents", { projectId: "p" });
await gw.readContext("p", "a");
expect(invoke).toHaveBeenCalledWith("read_agent_context", {
projectId: "p",
agentId: "a",
});
await gw.deleteAgent("p", "a");
expect(invoke).toHaveBeenCalledWith("delete_agent", {
projectId: "p",
agentId: "a",
});
});
});

View File

@ -0,0 +1,108 @@
/**
* Tauri adapter for {@link AgentGateway} (L6).
*
* NOTE: The Tauri commands wired here (`list_agents`, `create_agent`, …) are
* defined in the backend `app-tauri` crate and will be registered in a
* subsequent lot. This adapter is complete on the frontend side; the mock
* gateway covers tests and offline dev today. The real mode will work
* transparently once the commands are registered.
*
* Commands use snake_case (Tauri convention); payload keys are camelCase
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
* with the other adapters in this directory.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type { Agent } from "@/domain";
import type {
AgentGateway,
CreateAgentInput,
OpenTerminalOptions,
TerminalHandle,
} from "@/ports";
/** Wire shape returned by the `launch_agent` command (mirrors `open_terminal`). */
interface LaunchAgentResponse {
sessionId: string;
cwd: string;
rows: number;
cols: number;
}
export class TauriAgentGateway implements AgentGateway {
listAgents(projectId: string): Promise<Agent[]> {
return invoke<Agent[]>("list_agents", { projectId });
}
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
// The `create_agent` command takes a single `request` DTO; `projectId` must
// live *inside* it (camelCase), not at the top level.
return invoke<Agent>("create_agent", {
request: {
projectId,
name: input.name,
profileId: input.profileId,
initialContent: input.initialContent ?? null,
},
});
}
readContext(projectId: string, agentId: string): Promise<string> {
return invoke<string>("read_agent_context", { projectId, agentId });
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
// `update_agent_context` takes a single `request` DTO.
await invoke("update_agent_context", {
request: { projectId, agentId, content },
});
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
await invoke("delete_agent", { projectId, agentId });
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<LaunchAgentResponse>("launch_agent", {
request: {
projectId,
agentId,
rows: options.rows,
cols: options.cols,
},
onOutput: channel,
});
const sessionId = res.sessionId;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
await invoke("write_terminal", {
request: { sessionId, data: Array.from(data) },
});
},
async resize(rows: number, cols: number): Promise<void> {
await invoke("resize_terminal", {
request: { sessionId, rows, cols },
});
},
async close(): Promise<void> {
await invoke("close_terminal", { sessionId });
},
};
}
}

View File

@ -0,0 +1,53 @@
/**
* Tauri adapter for {@link GitGateway} (L8).
*
* Commands use snake_case (Tauri convention); payload keys are camelCase
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
* with the other adapters in this directory.
*
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
* crate. The mock gateway covers tests and offline dev today.
*/
import { invoke } from "@tauri-apps/api/core";
import type { GitBranches, GitCommit, GitFileStatus, GraphCommit } from "@/domain";
import type { GitGateway } from "@/ports";
export class TauriGitGateway implements GitGateway {
status(projectId: string): Promise<GitFileStatus[]> {
return invoke<GitFileStatus[]>("git_status", { projectId });
}
async stage(projectId: string, path: string): Promise<void> {
await invoke("git_stage", { request: { projectId, path } });
}
async unstage(projectId: string, path: string): Promise<void> {
await invoke("git_unstage", { request: { projectId, path } });
}
commit(projectId: string, message: string): Promise<GitCommit> {
return invoke<GitCommit>("git_commit", { request: { projectId, message } });
}
branches(projectId: string): Promise<GitBranches> {
return invoke<GitBranches>("git_branches", { projectId });
}
async checkout(projectId: string, branch: string): Promise<void> {
await invoke("git_checkout", { request: { projectId, branch } });
}
log(projectId: string, limit: number): Promise<GitCommit[]> {
return invoke<GitCommit[]>("git_log", { projectId, limit });
}
async init(projectId: string): Promise<void> {
await invoke("git_init", { projectId });
}
graph(projectId: string, limit: number): Promise<GraphCommit[]> {
return invoke<GraphCommit[]>("git_graph", { projectId, limit });
}
}

View File

@ -0,0 +1,62 @@
/**
* Real Tauri adapters wiring the UI ports to the backend via `@tauri-apps/api`.
*
* Only {@link TauriSystemGateway} is fully wired in L1 (to the `health`
* command). The remaining gateways are skeletons that throw `NOT_IMPLEMENTED`
* until their lots (L2L9) land — they exist so the DI surface is complete and
* the app can run real-mode without the mock substituting everything.
*/
import type { GatewayError } from "@/domain";
import type {
Gateways,
RemoteGateway,
} from "@/ports";
import { TauriSystemGateway } from "./system";
import { TauriAgentGateway } from "./agent";
import { TauriProjectGateway } from "./project";
import { TauriTerminalGateway } from "./terminal";
import { TauriLayoutGateway } from "./layout";
import { TauriProfileGateway } from "./profile";
import { TauriTemplateGateway } from "./template";
import { TauriGitGateway } from "./git";
function notImplemented(what: string): never {
const err: GatewayError = {
code: "NOT_IMPLEMENTED",
message: `${what} is not implemented yet (pending its lot).`,
};
throw err;
}
class TauriRemoteGateway implements RemoteGateway {
connect(): Promise<void> {
return notImplemented("RemoteGateway.connect");
}
}
/** Builds the full set of real Tauri-backed gateways. */
export function createTauriGateways(): Gateways {
return {
system: new TauriSystemGateway(),
agent: new TauriAgentGateway(),
terminal: new TauriTerminalGateway(),
project: new TauriProjectGateway(),
layout: new TauriLayoutGateway(),
git: new TauriGitGateway(),
remote: new TauriRemoteGateway(),
profile: new TauriProfileGateway(),
template: new TauriTemplateGateway(),
};
}
export {
TauriSystemGateway,
TauriAgentGateway,
TauriProjectGateway,
TauriTerminalGateway,
TauriLayoutGateway,
TauriProfileGateway,
TauriTemplateGateway,
TauriGitGateway,
};

View File

@ -0,0 +1,56 @@
/**
* Tauri adapter for {@link LayoutGateway} (L4). Together with the sibling
* adapters this is the *only* place that calls `invoke()`; components reach it
* exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
* The `load_layout`/`mutate_layout` commands return the layout tree directly
* (the backend `LayoutDto` is `#[serde(transparent)]` over the tree).
*/
import { invoke } from "@tauri-apps/api/core";
import type { LayoutKind, LayoutList, LayoutOperation, LayoutTree } from "@/domain";
import type { LayoutGateway } from "@/ports";
export class TauriLayoutGateway implements LayoutGateway {
loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
return invoke<LayoutTree>("load_layout", { projectId, layoutId });
}
mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree> {
return invoke<LayoutTree>("mutate_layout", { projectId, layoutId, operation });
}
listLayouts(projectId: string): Promise<LayoutList> {
return invoke<LayoutList>("list_layouts", { projectId });
}
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }> {
return invoke<{ layoutId: string }>("create_layout", {
request: { projectId, name, kind },
});
}
renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
return invoke<void>("rename_layout", {
request: { projectId, layoutId, name },
});
}
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }> {
return invoke<{ activeId: string }>("delete_layout", {
request: { projectId, layoutId },
});
}
setActiveLayout(projectId: string, layoutId: string): Promise<void> {
return invoke<void>("set_active_layout", {
request: { projectId, layoutId },
});
}
}

View File

@ -0,0 +1,925 @@
/**
* Mock gateways implementing every UI port in-memory, with no backend. They let
* the frontend run and be tested fully offline (ARCHITECTURE §1.3, §11) and are
* selected by the DI provider when `VITE_USE_MOCK` is set.
*/
import type {
Agent,
AgentDrift,
AgentProfile,
DomainEvent,
FirstRunState,
GatewayError,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutInfo,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
Project,
ProfileAvailability,
Template,
Unsubscribe,
} from "@/domain";
import type {
AgentGateway,
CreateAgentInput,
CreateTemplateInput,
Gateways,
GitGateway,
LayoutGateway,
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
RemoteGateway,
SystemGateway,
TemplateGateway,
TerminalGateway,
TerminalHandle,
} from "@/ports";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
async health(note?: string): Promise<HealthReport> {
const report: HealthReport = {
version: "0.1.0-mock",
alive: true,
timeMillis: Date.now(),
correlationId: `mock-${Math.random().toString(36).slice(2, 10)}`,
note: note ?? null,
};
// Emit a smoke event so subscribers can be exercised offline too.
this.emit({ type: "projectCreated", projectId: report.correlationId });
return report;
}
async onDomainEvent(
handler: (event: DomainEvent) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Test/dev helper to push an event to all subscribers. */
emit(event: DomainEvent): void {
for (const l of this.listeners) l(event);
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFolder(): Promise<string | null> {
return "/home/user/mock-project";
}
}
/**
* Slugifies a display name into a safe file stem (`[a-z0-9-]`), collapsing
* runs of non-alphanumeric characters into a single dash, mirroring the
* backend `slugify` logic.
*/
function slugify(name: string): string {
let out = "";
let prevDash = false;
for (const ch of name.trim()) {
if (/[a-zA-Z0-9]/.test(ch)) {
out += ch.toLowerCase();
prevDash = false;
} else if (!prevDash) {
out += "-";
prevDash = true;
}
}
return out.replace(/^-+|-+$/g, "");
}
/**
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
* `LaunchAgent` use cases, keyed per `projectId` (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly (same pattern as
* {@link MockProjectGateway}).
*/
export class MockAgentGateway implements AgentGateway {
/** Agents indexed by projectId. */
private agents = new Map<string, Agent[]>();
/** Context content indexed by `${projectId}::${agentId}`. */
private contexts = new Map<string, string>();
/** Monotonic session counter for deterministic session ids in tests. */
private sessionSeq = 0;
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
return this.agents.get(projectId)!;
}
private contextKey(projectId: string, agentId: string): string {
return `${projectId}::${agentId}`;
}
async listAgents(projectId: string): Promise<Agent[]> {
return structuredClone(this.getAgents(projectId));
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
const list = this.getAgents(projectId);
const slug = slugify(input.name) || "agent";
// Derive a unique agents/<slug>.md path, disambiguating with -2, -3, …
const existingPaths = new Set(list.map((a) => a.contextPath));
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name: input.name,
contextPath: candidate,
profileId: input.profileId,
origin: { type: "scratch" },
synchronized: false,
};
list.push(agent);
this.contexts.set(
this.contextKey(projectId, agent.id),
input.initialContent ?? "",
);
return structuredClone(agent);
}
async readContext(projectId: string, agentId: string): Promise<string> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
return this.contexts.get(this.contextKey(projectId, agentId)) ?? "";
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.contexts.set(this.contextKey(projectId, agentId), content);
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
list.splice(idx, 1);
this.contexts.delete(this.contextKey(projectId, agentId));
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
* Inserts a pre-built agent record into the registry.
* Used by `MockTemplateGateway.createAgentFromTemplate` so both gateways
* share the same in-memory store.
*/
_insertAgent(projectId: string, agent: Agent, context: string): void {
const list = this.getAgents(projectId);
list.push(agent);
this.contexts.set(this.contextKey(projectId, agent.id), context);
}
/**
* Updates an agent record in-place (origin + synchronized flag).
* Used by `MockTemplateGateway.syncAgent`.
*/
_updateAgent(
projectId: string,
agentId: string,
patch: Partial<Pick<Agent, "origin" | "synchronized">>,
newContext?: string,
): void {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) return;
list[idx] = { ...list[idx], ...patch };
if (newContext !== undefined) {
this.contexts.set(this.contextKey(projectId, agentId), newContext);
}
}
/**
* Returns a **live** (not cloned) reference to the agent list so
* `MockTemplateGateway` can read agent origins without triggering async overhead.
*/
_rawAgents(projectId: string): Agent[] {
return this.getAgents(projectId);
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
onData(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
let closed = false;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
};
}
}
/**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
* back to `onData` (so the xterm wrapper renders typed input) and greets on
* open. Lets the terminal feature run and be tested fully offline.
*/
export class MockTerminalGateway implements TerminalGateway {
private seq = 0;
async openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
this.seq += 1;
const sessionId = `mock-session-${this.seq}`;
const enc = new TextEncoder();
// Greet so something is visible immediately.
queueMicrotask(() =>
onData(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
);
let closed = false;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
if (closed) return;
// Echo back, translating CR to CRLF like a cooked terminal.
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
onData(Uint8Array.from(out));
},
async resize(): Promise<void> {},
async close(): Promise<void> {
closed = true;
},
};
}
}
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
async listProjects(): Promise<Project[]> {
return [...this.projects];
}
async createProject(name: string, root: string): Promise<Project> {
if (this.projects.some((p) => p.root === root)) {
const err: GatewayError = {
code: "INVALID",
message: `a project already exists at ${root} for this remote`,
};
throw err;
}
const project: Project = {
id: `mock-project-${Math.random().toString(36).slice(2, 10)}`,
name,
root,
remote: { kind: "local" },
createdAt: Date.now(),
};
this.projects.push(project);
return project;
}
async openProject(projectId: string): Promise<Project> {
const project = this.projects.find((p) => p.id === projectId);
if (!project) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
return project;
}
async closeProject(): Promise<void> {}
}
/** Internal per-project layout store entry. */
interface MockLayoutEntry {
id: string;
name: string;
kind: LayoutKind;
tree: LayoutTree;
}
/** Internal per-project layout state: list of named layouts + active id. */
interface MockProjectLayouts {
activeId: string;
layouts: MockLayoutEntry[];
}
/**
* In-memory layout store: keeps multiple named {@link LayoutTree}s per project,
* applying operations with the same pure logic as the backend (`applyOperation`).
* Lets the grid feature run and be tested fully offline.
*
* Each project starts with a single "Default" layout. The old `loadLayout` /
* `mutateLayout` signatures (no `layoutId`) default to the active layout,
* preserving backward-compat with existing tests.
*/
export class MockLayoutGateway implements LayoutGateway {
private store = new Map<string, MockProjectLayouts>();
private getProjectLayouts(projectId: string): MockProjectLayouts {
if (!this.store.has(projectId)) {
const defaultId = `layout-default-${Math.random().toString(36).slice(2, 8)}`;
this.store.set(projectId, {
activeId: defaultId,
layouts: [{ id: defaultId, name: "Default", kind: "terminal", tree: singleLeafTree() }],
});
}
return this.store.get(projectId)!;
}
private getActiveTree(projectId: string): LayoutTree {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === ps.activeId);
return entry ? entry.tree : singleLeafTree();
}
private getTree(projectId: string, layoutId?: string): LayoutTree {
if (!layoutId) return this.getActiveTree(projectId);
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
return entry ? entry.tree : singleLeafTree();
}
private setTree(projectId: string, tree: LayoutTree, layoutId?: string): void {
const ps = this.getProjectLayouts(projectId);
const id = layoutId ?? ps.activeId;
const entry = ps.layouts.find((l) => l.id === id);
if (entry) entry.tree = tree;
}
async loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
const tree = this.getTree(projectId, layoutId);
return structuredClone(tree);
}
async mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree> {
const current = this.getTree(projectId, layoutId);
const next = applyOperation(current, operation);
this.setTree(projectId, next, layoutId);
return structuredClone(next);
}
async listLayouts(projectId: string): Promise<LayoutList> {
const ps = this.getProjectLayouts(projectId);
const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind }));
return { layouts, activeId: ps.activeId };
}
async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> {
const ps = this.getProjectLayouts(projectId);
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() });
return { layoutId };
}
async renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
if (entry) entry.name = name;
}
async deleteLayout(
projectId: string,
layoutId: string,
): Promise<{ activeId: string }> {
const ps = this.getProjectLayouts(projectId);
const idx = ps.layouts.findIndex((l) => l.id === layoutId);
if (idx !== -1) ps.layouts.splice(idx, 1);
// If the deleted layout was active, switch to the first remaining layout.
if (ps.activeId === layoutId && ps.layouts.length > 0) {
ps.activeId = ps.layouts[0].id;
}
return { activeId: ps.activeId };
}
async setActiveLayout(projectId: string, layoutId: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
if (ps.layouts.some((l) => l.id === layoutId)) {
ps.activeId = layoutId;
}
}
}
/** Per-project git state kept in the mock. */
interface MockGitProjectState {
files: Map<string, boolean>; // path → staged
branches: string[];
current: string;
log: GitCommit[];
commitSeq: number;
}
/**
* A small demo DAG that exercises branches, a merge commit, and a tag.
*
* Topology (newest first, as git log returns):
*
* e (main, HEAD) — merge commit from feature
* ├─ d (feature)
* │ └─ c
* └─ b
* └─ a (tag: v1.0)
*
* In list form (parents reference earlier hashes):
* e parents=[b,d]
* d parents=[c]
* c parents=[a] (feature branch diverges from a)
* b parents=[a]
* a parents=[] (initial commit, tag v1.0)
*/
const DEMO_GRAPH_COMMITS: GraphCommit[] = [
{
hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
summary: "Merge feature into main",
parents: [
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"dddddddddddddddddddddddddddddddddddddddd",
],
refs: ["main", "HEAD"],
author: "Alice",
timestamp: 1_717_200_000,
},
{
hash: "dddddddddddddddddddddddddddddddddddddddd",
summary: "Implement feature (step 2)",
parents: ["cccccccccccccccccccccccccccccccccccccccc"],
refs: ["feature"],
author: "Bob",
timestamp: 1_717_100_000,
},
{
hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
summary: "Hotfix on main",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Alice",
timestamp: 1_717_090_000,
},
{
hash: "cccccccccccccccccccccccccccccccccccccccc",
summary: "Implement feature (step 1)",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Bob",
timestamp: 1_717_080_000,
},
{
hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
summary: "Initial commit",
parents: [],
refs: ["tag: v1.0"],
author: "Alice",
timestamp: 1_717_000_000,
},
];
/**
* Stateful in-memory git gateway — simulates a git repository per project
* (keyed by projectId). Seeded with demo files so the panel renders something
* on first render.
*
* Exported so tests can instantiate it directly.
*/
export class MockGitGateway implements GitGateway {
private projects = new Map<string, MockGitProjectState>();
private getState(projectId: string): MockGitProjectState {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
return this.projects.get(projectId)!;
}
private _seedState(): MockGitProjectState {
const files = new Map<string, boolean>();
files.set("src/main.rs", false);
files.set("README.md", false);
return {
files,
branches: ["main"],
current: "main",
log: [],
commitSeq: 0,
};
}
async init(projectId: string): Promise<void> {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
}
async status(projectId: string): Promise<GitFileStatus[]> {
const state = this.getState(projectId);
return Array.from(state.files.entries()).map(([path, staged]) => ({
path,
staged,
}));
}
async stage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, true);
}
}
async unstage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, false);
}
}
async commit(projectId: string, message: string): Promise<GitCommit> {
const state = this.getState(projectId);
state.commitSeq += 1;
const gitCommit: GitCommit = {
hash: `mock-${state.commitSeq}`,
summary: message.split("\n")[0],
};
// Remove staged files from the working tree status.
for (const [path, staged] of Array.from(state.files.entries())) {
if (staged) state.files.delete(path);
}
// Push commit to the top of the log.
state.log.unshift(gitCommit);
return gitCommit;
}
async branches(projectId: string): Promise<GitBranches> {
const state = this.getState(projectId);
return { branches: [...state.branches], current: state.current };
}
async checkout(projectId: string, branch: string): Promise<void> {
const state = this.getState(projectId);
if (!state.branches.includes(branch)) {
state.branches.push(branch);
}
state.current = branch;
}
async log(projectId: string, limit: number): Promise<GitCommit[]> {
const state = this.getState(projectId);
return state.log.slice(0, limit);
}
async graph(_projectId: string, limit: number): Promise<GraphCommit[]> {
return DEMO_GRAPH_COMMITS.slice(0, limit);
}
}
class MockRemoteGateway implements RemoteGateway {
async connect(): Promise<void> {}
}
/** The pre-filled reference catalogue the mock serves (mirror of the backend). */
export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
{
id: "mock-claude",
name: "Claude Code",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: "claude --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-codex",
name: "OpenAI Codex CLI",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-gemini",
name: "Gemini CLI",
command: "gemini",
args: [],
contextInjection: { strategy: "conventionFile", target: "GEMINI.md" },
detect: "gemini --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-aider",
name: "Aider",
command: "aider",
args: [],
contextInjection: { strategy: "flag", flag: "--message-file {path}" },
detect: "aider --version",
cwdTemplate: "{projectRoot}",
},
];
/**
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
* the wizard can be driven and tested fully offline. By default it reports the
* first run as *not done* until {@link configureProfiles} is called. Detection
* marks a fixed subset (claude) as installed so ✓/✗ rendering is exercised.
*/
export class MockProfileGateway implements ProfileGateway {
private profiles: AgentProfile[] = [];
private configured = false;
async firstRunState(): Promise<FirstRunState> {
return {
isFirstRun: !this.configured,
referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES),
};
}
async referenceProfiles(): Promise<AgentProfile[]> {
return structuredClone(MOCK_REFERENCE_PROFILES);
}
async detectProfiles(
candidates: AgentProfile[],
): Promise<ProfileAvailability[]> {
// Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗.
return candidates.map((profile) => ({
profile,
available: profile.command === "claude",
}));
}
async listProfiles(): Promise<AgentProfile[]> {
return structuredClone(this.profiles);
}
async saveProfile(profile: AgentProfile): Promise<AgentProfile> {
const i = this.profiles.findIndex((p) => p.id === profile.id);
if (i >= 0) this.profiles[i] = profile;
else this.profiles.push(profile);
this.configured = true;
return structuredClone(profile);
}
async deleteProfile(profileId: string): Promise<void> {
this.profiles = this.profiles.filter((p) => p.id !== profileId);
}
async configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
this.profiles = structuredClone(profiles);
this.configured = true;
return structuredClone(profiles);
}
}
/**
* Stateful in-memory template gateway.
*
* Shares the `MockAgentGateway` instance passed at construction time so that
* `createAgentFromTemplate` / `detectDrift` / `syncAgent` operate on the same
* agent registry as the rest of the UI (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly and inject a shared
* `MockAgentGateway`.
*/
export class MockTemplateGateway implements TemplateGateway {
private templates: Template[] = [];
private seq = 0;
constructor(private readonly agentGateway: MockAgentGateway) {}
async listTemplates(): Promise<Template[]> {
return structuredClone(this.templates);
}
async createTemplate(input: CreateTemplateInput): Promise<Template> {
this.seq += 1;
const template: Template = {
id: `mock-template-${this.seq}`,
name: input.name,
contentMd: input.content,
version: 1,
defaultProfileId: input.defaultProfileId,
};
this.templates.push(template);
return structuredClone(template);
}
async updateTemplate(templateId: string, content: string): Promise<Template> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates[idx] = {
...this.templates[idx],
contentMd: content,
version: this.templates[idx].version + 1,
};
return structuredClone(this.templates[idx]);
}
async deleteTemplate(templateId: string): Promise<void> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates.splice(idx, 1);
}
async createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent> {
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
const synchronized = opts?.synchronized ?? true;
const name = opts?.name ?? template.name;
const slug = slugify(name) || "agent";
// Derive unique contextPath (same logic as MockAgentGateway)
const existingPaths = new Set(
this.agentGateway._rawAgents(projectId).map((a) => a.contextPath),
);
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name,
contextPath: candidate,
profileId: template.defaultProfileId,
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
synchronized,
};
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
return structuredClone(agent);
}
async detectDrift(projectId: string): Promise<AgentDrift[]> {
const agents = this.agentGateway._rawAgents(projectId);
const drifts: AgentDrift[] = [];
for (const agent of agents) {
if (!agent.synchronized) continue;
if (agent.origin.type !== "fromTemplate") continue;
const { templateId, syncedTemplateVersion } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) continue;
if (template.version > syncedTemplateVersion) {
drifts.push({
agentId: agent.id,
from: syncedTemplateVersion,
to: template.version,
});
}
}
return drifts;
}
async syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }> {
const agents = this.agentGateway._rawAgents(projectId);
const agent = agents.find((a) => a.id === agentId);
if (!agent || !agent.synchronized || agent.origin.type !== "fromTemplate") {
return { synced: false, version: null };
}
const { templateId } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
return { synced: false, version: null };
}
this.agentGateway._updateAgent(
projectId,
agentId,
{
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
},
template.contentMd,
);
return { synced: true, version: template.version };
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
return {
system: new MockSystemGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
project: new MockProjectGateway(),
layout: new MockLayoutGateway(),
git: new MockGitGateway(),
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
};
}
// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };

View File

@ -0,0 +1,164 @@
/**
* L4 — behavioural tests for {@link MockLayoutGateway}: it defaults to a single
* empty leaf, applies mutations and returns the new tree, and keeps a separate
* in-memory tree per project.
*/
import { describe, it, expect } from "vitest";
import { leaves } from "@/features/layout/layout";
import { MockLayoutGateway } from "./index";
describe("MockLayoutGateway", () => {
it("loadLayout defaults to a single empty leaf", async () => {
const gw = new MockLayoutGateway();
const tree = await gw.loadLayout("p1");
expect(tree.root.type).toBe("leaf");
const ls = leaves(tree);
expect(ls).toHaveLength(1);
expect(ls[0].session ?? null).toBeNull();
});
it("loadLayout returns a stable tree for the same project", async () => {
const gw = new MockLayoutGateway();
const a = await gw.loadLayout("p1");
const b = await gw.loadLayout("p1");
expect(b).toEqual(a); // same persisted tree (id preserved)
});
it("mutateLayout applies the op and returns the mutated tree", async () => {
const gw = new MockLayoutGateway();
const initial = await gw.loadLayout("p1");
const leafId = leaves(initial)[0].id;
const next = await gw.mutateLayout("p1", {
type: "split",
target: leafId,
direction: "row",
newLeaf: "b",
container: "c",
});
expect(next.root.type).toBe("split");
expect(leaves(next)).toHaveLength(2);
});
it("persists the mutation in memory (next load reflects it)", async () => {
const gw = new MockLayoutGateway();
const initial = await gw.loadLayout("p1");
const leafId = leaves(initial)[0].id;
await gw.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "s9",
});
const reloaded = await gw.loadLayout("p1");
expect(leaves(reloaded)[0].session).toBe("s9");
});
it("keeps a separate tree per project", async () => {
const gw = new MockLayoutGateway();
const p1 = await gw.loadLayout("p1");
const p2 = await gw.loadLayout("p2");
await gw.mutateLayout("p1", {
type: "split",
target: leaves(p1)[0].id,
direction: "row",
newLeaf: "b",
container: "c",
});
// p2 is untouched.
const p2After = await gw.loadLayout("p2");
expect(p2After).toEqual(p2);
expect(leaves(p2After)).toHaveLength(1);
});
it("returns clones, so mutating a returned tree does not corrupt the store", async () => {
const gw = new MockLayoutGateway();
const tree = await gw.loadLayout("p1");
if (tree.root.type === "leaf") tree.root.node.session = "tampered";
const fresh = await gw.loadLayout("p1");
expect(leaves(fresh)[0].session ?? null).toBeNull();
});
// ── Multi-layout tests (#4) ────────────────────────────────────────────────
it("listLayouts returns a 'Default' layout on first call", async () => {
const gw = new MockLayoutGateway();
const { layouts, activeId } = await gw.listLayouts("p1");
expect(layouts).toHaveLength(1);
expect(layouts[0].name).toBe("Default");
expect(activeId).toBe(layouts[0].id);
});
it("createLayout adds a new named layout", async () => {
const gw = new MockLayoutGateway();
const { layoutId } = await gw.createLayout("p1", "Beta");
const { layouts } = await gw.listLayouts("p1");
expect(layouts).toHaveLength(2);
expect(layouts.some((l) => l.id === layoutId && l.name === "Beta")).toBe(true);
});
it("renameLayout changes the layout name", async () => {
const gw = new MockLayoutGateway();
const { layouts } = await gw.listLayouts("p1");
await gw.renameLayout("p1", layouts[0].id, "Renamed");
const { layouts: updated } = await gw.listLayouts("p1");
expect(updated[0].name).toBe("Renamed");
});
it("deleteLayout removes a layout and adjusts the active id", async () => {
const gw = new MockLayoutGateway();
const { layoutId: secondId } = await gw.createLayout("p1", "Second");
await gw.setActiveLayout("p1", secondId);
const { activeId } = await gw.deleteLayout("p1", secondId);
const { layouts, activeId: newActive } = await gw.listLayouts("p1");
expect(layouts).toHaveLength(1);
expect(layouts.some((l) => l.id === secondId)).toBe(false);
// active should have switched back to the Default.
expect(activeId).toBe(newActive);
});
it("setActiveLayout switches the active layout", async () => {
const gw = new MockLayoutGateway();
const { layoutId } = await gw.createLayout("p1", "Alt");
await gw.setActiveLayout("p1", layoutId);
const { activeId } = await gw.listLayouts("p1");
expect(activeId).toBe(layoutId);
});
it("loadLayout with a specific layoutId loads that layout's tree", async () => {
const gw = new MockLayoutGateway();
const { layoutId } = await gw.createLayout("p1", "Named");
// Mutate the named layout.
const tree = await gw.loadLayout("p1", layoutId);
const leafId = leaves(tree)[0].id;
await gw.mutateLayout("p1", { type: "setSession", target: leafId, session: "s-named" }, layoutId);
// The default (active) layout should be untouched.
const defaultTree = await gw.loadLayout("p1");
expect(leaves(defaultTree)[0].session ?? null).toBeNull();
// The named layout should have the session.
const namedTree = await gw.loadLayout("p1", layoutId);
expect(leaves(namedTree)[0].session).toBe("s-named");
});
it("setCellAgent persists and clears correctly in applyOperation", async () => {
const gw = new MockLayoutGateway();
const tree = await gw.loadLayout("p1");
const leafId = leaves(tree)[0].id;
const withAgent = await gw.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: "agent-99",
});
expect(leaves(withAgent)[0].agent).toBe("agent-99");
const cleared = await gw.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: null,
});
expect(leaves(cleared)[0].agent).toBeUndefined();
});
});

View File

@ -0,0 +1,89 @@
/**
* L1 — each mock gateway satisfies its port interface (typecheck via the typed
* `Gateways` binding below) and behaves sensibly offline.
*/
import { describe, it, expect } from "vitest";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { createMockGateways, MockSystemGateway } from "./index";
// Typechecking this assignment proves every mock implements its port.
const gateways: Gateways = createMockGateways();
describe("createMockGateways", () => {
it("exposes all nine gateways", () => {
expect(Object.keys(gateways).sort()).toEqual([
"agent",
"git",
"layout",
"profile",
"project",
"remote",
"system",
"template",
"terminal",
]);
});
});
describe("MockSystemGateway", () => {
it("health returns an alive report echoing the note", async () => {
const sys = new MockSystemGateway();
const report = await sys.health("ping");
expect(report.alive).toBe(true);
expect(report.note).toBe("ping");
expect(typeof report.version).toBe("string");
expect(typeof report.timeMillis).toBe("number");
expect(typeof report.correlationId).toBe("string");
});
it("health note defaults to null when omitted", async () => {
const report = await new MockSystemGateway().health();
expect(report.note).toBeNull();
});
it("delivers emitted domain events to subscribers and stops after unsubscribe", async () => {
const sys = new MockSystemGateway();
const received: DomainEvent[] = [];
const unsub = await sys.onDomainEvent((e) => received.push(e));
sys.emit({ type: "projectCreated", projectId: "p1" });
expect(received).toHaveLength(1);
expect(received[0]).toEqual({ type: "projectCreated", projectId: "p1" });
unsub();
sys.emit({ type: "projectCreated", projectId: "p2" });
expect(received).toHaveLength(1);
});
it("health emits a smoke domain event to subscribers", async () => {
const sys = new MockSystemGateway();
const received: DomainEvent[] = [];
await sys.onDomainEvent((e) => received.push(e));
await sys.health();
expect(received.some((e) => e.type === "projectCreated")).toBe(true);
});
});
describe("other mock gateways", () => {
it("project.createProject returns a project and listProjects reflects it", async () => {
expect(await gateways.project.listProjects()).toEqual([]);
const created = await gateways.project.createProject("n", "/root");
expect(created.name).toBe("n");
expect(created.root).toBe("/root");
expect(typeof created.id).toBe("string");
});
it("terminal.openTerminal returns handles with incrementing session ids", async () => {
const a = await gateways.terminal.openTerminal({ cwd: "/cwd", rows: 24, cols: 80 }, () => {});
const b = await gateways.terminal.openTerminal({ cwd: "/cwd", rows: 24, cols: 80 }, () => {});
expect(a.sessionId).not.toEqual(b.sessionId);
});
it("git.branches returns main as current branch", async () => {
const b = await gateways.git.branches("p");
expect(b.current).toBe("main");
expect(b.branches).toContain("main");
});
});

View File

@ -0,0 +1,94 @@
/**
* L5 — the in-memory {@link MockProfileGateway}: first-run flag lifecycle,
* reference catalogue, simulated detection (only `claude` installed), and the
* save/list/delete/configure CRUD.
*/
import { describe, it, expect } from "vitest";
import type { AgentProfile } from "@/domain";
import { MockProfileGateway, MOCK_REFERENCE_PROFILES } from "./index";
function customProfile(id: string, command: string): AgentProfile {
return {
id,
name: `Custom ${id}`,
command,
args: [],
contextInjection: { strategy: "stdin" },
detect: null,
cwdTemplate: "{projectRoot}",
};
}
describe("MockProfileGateway", () => {
it("firstRunState is first-run with the four reference profiles", async () => {
const gw = new MockProfileGateway();
const state = await gw.firstRunState();
expect(state.isFirstRun).toBe(true);
expect(state.referenceProfiles.map((p) => p.command)).toEqual([
"claude",
"codex",
"gemini",
"aider",
]);
});
it("referenceProfiles returns a clone of the catalogue", async () => {
const gw = new MockProfileGateway();
const refs = await gw.referenceProfiles();
expect(refs).toEqual(MOCK_REFERENCE_PROFILES);
refs[0].command = "mutated";
const again = await gw.referenceProfiles();
expect(again[0].command).toBe("claude");
});
it("detectProfiles marks only claude as installed", async () => {
const gw = new MockProfileGateway();
const results = await gw.detectProfiles([...MOCK_REFERENCE_PROFILES]);
const byCommand = Object.fromEntries(
results.map((r) => [r.profile.command, r.available]),
);
expect(byCommand).toEqual({
claude: true,
codex: false,
gemini: false,
aider: false,
});
});
it("saveProfile upserts and listProfiles reflects it", async () => {
const gw = new MockProfileGateway();
await gw.saveProfile(customProfile("c1", "foo"));
await gw.saveProfile(customProfile("c1", "bar")); // same id ⇒ replace
const list = await gw.listProfiles();
expect(list).toHaveLength(1);
expect(list[0].command).toBe("bar");
});
it("deleteProfile removes by id", async () => {
const gw = new MockProfileGateway();
await gw.saveProfile(customProfile("a", "a"));
await gw.saveProfile(customProfile("b", "b"));
await gw.deleteProfile("a");
const list = await gw.listProfiles();
expect(list.map((p) => p.id)).toEqual(["b"]);
});
it("configureProfiles persists the batch and closes the first run", async () => {
const gw = new MockProfileGateway();
expect((await gw.firstRunState()).isFirstRun).toBe(true);
const chosen = [customProfile("x", "x")];
const out = await gw.configureProfiles(chosen);
expect(out).toEqual(chosen);
expect((await gw.firstRunState()).isFirstRun).toBe(false);
expect(await gw.listProfiles()).toEqual(chosen);
});
it("configureProfiles with an empty list still closes the first run", async () => {
const gw = new MockProfileGateway();
await gw.configureProfiles([]);
expect((await gw.firstRunState()).isFirstRun).toBe(false);
});
});

View File

@ -0,0 +1,114 @@
/**
* L3 — behavioural tests for {@link MockTerminalGateway}: it greets on open,
* echoes writes back through `onData` (translating CR→CRLF like a cooked
* terminal), behaves on resize/close, and mints distinct session ids.
*/
import { describe, it, expect, vi } from "vitest";
import { MockTerminalGateway } from "./index";
/** Flush queued microtasks (the greeting is delivered via `queueMicrotask`). */
async function flushMicrotasks() {
await Promise.resolve();
await Promise.resolve();
}
function decode(chunks: Uint8Array[]): string {
const dec = new TextDecoder();
return chunks.map((c) => dec.decode(c)).join("");
}
describe("MockTerminalGateway", () => {
it("greets on open with the cwd via onData", async () => {
const gw = new MockTerminalGateway();
const received: Uint8Array[] = [];
await gw.openTerminal({ cwd: "/work/dir", rows: 24, cols: 80 }, (b) =>
received.push(b),
);
await flushMicrotasks();
expect(decode(received)).toContain("/work/dir");
});
it("returns a handle with a sessionId and write/resize/close methods", async () => {
const gw = new MockTerminalGateway();
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
() => {},
);
expect(typeof handle.sessionId).toBe("string");
expect(typeof handle.write).toBe("function");
expect(typeof handle.resize).toBe("function");
expect(typeof handle.close).toBe("function");
});
it("echoes written bytes back through onData", async () => {
const gw = new MockTerminalGateway();
const received: Uint8Array[] = [];
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
(b) => received.push(b),
);
await flushMicrotasks();
received.length = 0; // drop the greeting
await handle.write(new TextEncoder().encode("hi"));
expect(decode(received)).toBe("hi");
});
it("translates a CR keystroke to CRLF on echo", async () => {
const gw = new MockTerminalGateway();
const received: Uint8Array[] = [];
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
(b) => received.push(b),
);
await flushMicrotasks();
received.length = 0;
// 0x0d == CR
await handle.write(Uint8Array.from([0x61, 0x0d])); // 'a' + CR
const out = received.flatMap((c) => Array.from(c));
expect(out).toEqual([0x61, 0x0d, 0x0a]); // 'a', CR, LF
});
it("stops echoing once closed", async () => {
const gw = new MockTerminalGateway();
const received: Uint8Array[] = [];
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
(b) => received.push(b),
);
await flushMicrotasks();
received.length = 0;
await handle.close();
await handle.write(new TextEncoder().encode("ignored"));
expect(received).toHaveLength(0);
});
it("resize resolves without throwing", async () => {
const gw = new MockTerminalGateway();
const handle = await gw.openTerminal(
{ cwd: "/c", rows: 24, cols: 80 },
() => {},
);
await expect(handle.resize(40, 120)).resolves.toBeUndefined();
});
it("mints distinct session ids for two opens", async () => {
const gw = new MockTerminalGateway();
const a = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {});
const b = await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, () => {});
expect(a.sessionId).not.toEqual(b.sessionId);
});
it("does not echo before any write (only the greeting)", async () => {
const gw = new MockTerminalGateway();
const onData = vi.fn();
await gw.openTerminal({ cwd: "/c", rows: 24, cols: 80 }, onData);
await flushMicrotasks();
// Exactly one delivery so far: the greeting.
expect(onData).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,46 @@
/**
* Tauri adapter for {@link ProfileGateway} (L5). Like the sibling adapters this
* is one of the *only* places that calls `invoke()`; components reach it
* exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
*/
import { invoke } from "@tauri-apps/api/core";
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
import type { ProfileGateway } from "@/ports";
export class TauriProfileGateway implements ProfileGateway {
firstRunState(): Promise<FirstRunState> {
return invoke<FirstRunState>("first_run_state");
}
referenceProfiles(): Promise<AgentProfile[]> {
return invoke<AgentProfile[]>("reference_profiles");
}
detectProfiles(candidates: AgentProfile[]): Promise<ProfileAvailability[]> {
return invoke<ProfileAvailability[]>("detect_profiles", {
request: { candidates },
});
}
listProfiles(): Promise<AgentProfile[]> {
return invoke<AgentProfile[]>("list_profiles");
}
saveProfile(profile: AgentProfile): Promise<AgentProfile> {
return invoke<AgentProfile>("save_profile", { request: { profile } });
}
async deleteProfile(profileId: string): Promise<void> {
await invoke("delete_profile", { profileId });
}
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
return invoke<AgentProfile[]>("configure_profiles", {
request: { profiles },
});
}
}

View File

@ -0,0 +1,30 @@
/**
* Tauri adapter for {@link ProjectGateway} (L2). Together with the sibling
* adapters this is the *only* place that calls `invoke()`; components reach it
* exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
*/
import { invoke } from "@tauri-apps/api/core";
import type { Project } from "@/domain";
import type { ProjectGateway } from "@/ports";
export class TauriProjectGateway implements ProjectGateway {
listProjects(): Promise<Project[]> {
return invoke<Project[]>("list_projects");
}
createProject(name: string, root: string): Promise<Project> {
return invoke<Project>("create_project", { request: { name, root } });
}
openProject(projectId: string): Promise<Project> {
return invoke<Project>("open_project", { projectId });
}
async closeProject(projectId: string): Promise<void> {
await invoke("close_project", { projectId });
}
}

View File

@ -0,0 +1,40 @@
/**
* Tauri adapter for {@link SystemGateway}. This is the *only* place (together
* with the sibling adapters) that touches `@tauri-apps/api`. Components reach it
* exclusively through the port — never `invoke()` directly.
*/
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import type { DomainEvent, HealthReport, Unsubscribe } from "@/domain";
import type { SystemGateway } from "@/ports";
/** Tauri event name carrying relayed domain events (mirror of `DOMAIN_EVENT`). */
const DOMAIN_EVENT = "domain://event";
export class TauriSystemGateway implements SystemGateway {
async health(note?: string): Promise<HealthReport> {
// The backend command takes an optional `request: { note }` (camelCase).
return invoke<HealthReport>("health", {
request: note === undefined ? null : { note },
});
}
async onDomainEvent(
handler: (event: DomainEvent) => void,
): Promise<Unsubscribe> {
const unlisten = await listen<DomainEvent>(DOMAIN_EVENT, (e) => {
handler(e.payload);
});
return unlisten;
}
async pickFolder(): Promise<string | null> {
const result = await open({ directory: true, multiple: false });
if (result === null || result === undefined) return null;
// `open` with `multiple: false` returns a string when a path is chosen.
return typeof result === "string" ? result : null;
}
}

View File

@ -0,0 +1,71 @@
/**
* Tauri adapter for {@link TemplateGateway} (L7).
*
* Commands use snake_case (Tauri convention); payload keys are camelCase
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
* with the other adapters in this directory.
*
* NOTE: The Tauri commands wired here are defined in the backend `app-tauri`
* crate and will be registered in a subsequent lot. This adapter is complete
* on the frontend side; the mock gateway covers tests and offline dev today.
*/
import { invoke } from "@tauri-apps/api/core";
import type { Agent, AgentDrift, Template } from "@/domain";
import type { CreateTemplateInput, TemplateGateway } from "@/ports";
export class TauriTemplateGateway implements TemplateGateway {
listTemplates(): Promise<Template[]> {
return invoke<Template[]>("list_templates");
}
createTemplate(input: CreateTemplateInput): Promise<Template> {
return invoke<Template>("create_template", {
request: {
name: input.name,
content: input.content,
defaultProfileId: input.defaultProfileId,
},
});
}
updateTemplate(templateId: string, content: string): Promise<Template> {
return invoke<Template>("update_template", {
request: { templateId, content },
});
}
async deleteTemplate(templateId: string): Promise<void> {
await invoke("delete_template", { templateId });
}
createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent> {
return invoke<Agent>("create_agent_from_template", {
request: {
projectId,
templateId,
name: opts?.name ?? null,
synchronized: opts?.synchronized ?? true,
},
});
}
detectDrift(projectId: string): Promise<AgentDrift[]> {
return invoke<AgentDrift[]>("detect_agent_drift", { projectId });
}
syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }> {
return invoke<{ synced: boolean; version: number | null }>(
"sync_agent_with_template",
{ request: { projectId, agentId } },
);
}
}

View File

@ -0,0 +1,65 @@
/**
* Tauri adapter for {@link TerminalGateway} (L3). The single place that uses a
* {@link Channel} for the high-frequency PTY byte stream and `invoke()` for the
* control commands. Components reach it exclusively through the port.
*
* Flow (ARCHITECTURE §2 "Tauri Channels"):
* - `openTerminal` creates a `Channel<number[]>`, passes it to the
* `open_terminal` command, and forwards every chunk to `onData` as a
* `Uint8Array`. The backend pumps PTY output into that channel via the
* `PtyBridge`.
* - keystrokes go out through `write_terminal`, resize through
* `resize_terminal`, teardown through `close_terminal`.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
*/
import { Channel, invoke } from "@tauri-apps/api/core";
import type {
OpenTerminalOptions,
TerminalGateway,
TerminalHandle,
} from "@/ports";
/** Wire shape returned by the `open_terminal` command. */
interface OpenTerminalResponse {
sessionId: string;
cwd: string;
rows: number;
cols: number;
}
export class TauriTerminalGateway implements TerminalGateway {
async openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
// Per-session output channel. The backend serialises chunks as byte arrays.
const channel = new Channel<number[]>();
channel.onmessage = (chunk) => onData(Uint8Array.from(chunk));
const res = await invoke<OpenTerminalResponse>("open_terminal", {
request: { cwd: options.cwd, rows: options.rows, cols: options.cols },
onOutput: channel,
});
const sessionId = res.sessionId;
return {
sessionId,
async write(data: Uint8Array): Promise<void> {
await invoke("write_terminal", {
request: { sessionId, data: Array.from(data) },
});
},
async resize(rows: number, cols: number): Promise<void> {
await invoke("resize_terminal", {
request: { sessionId, rows, cols },
});
},
async close(): Promise<void> {
await invoke("close_terminal", { sessionId });
},
};
}
}

140
frontend/src/app/App.tsx Normal file
View File

@ -0,0 +1,140 @@
/**
* Root component. Smoke-tests the full hexagonal pipeline by calling `health`
* through the {@link SystemGateway} (never `invoke()` directly) and listening
* for relayed domain events.
*/
import { useEffect, useState } from "react";
import type { DomainEvent, HealthReport } from "@/domain";
import { ProjectsView } from "@/features/projects";
import { FirstRunWizard, ProfilesSettings } from "@/features/first-run";
import { Button, Panel, Spinner, Toolbar } from "@/shared";
import { useGateways, shouldUseMock } from "./di";
export function App() {
const { system, profile } = useGateways();
const [health, setHealth] = useState<HealthReport | null>(null);
const [error, setError] = useState<string | null>(null);
const [events, setEvents] = useState<DomainEvent[]>([]);
// First-run gating: null while loading, then true (wizard) / false (normal).
const [firstRun, setFirstRun] = useState<boolean | null>(null);
const [showSettings, setShowSettings] = useState(false);
useEffect(() => {
let unsub: (() => void) | undefined;
let cancelled = false;
system
.onDomainEvent((e) => setEvents((prev) => [...prev, e]))
.then((u) => {
if (cancelled) u();
else unsub = u;
})
.catch(() => {
/* event relay unavailable in this environment; ignore for smoke test */
});
system
.health("ui-boot")
.then((report) => setHealth(report))
.catch((e: unknown) => setError(describeError(e)));
return () => {
cancelled = true;
unsub?.();
};
}, [system]);
useEffect(() => {
let cancelled = false;
profile
.firstRunState()
.then((s) => {
if (!cancelled) setFirstRun(s.isFirstRun);
})
.catch(() => {
// First-run state unavailable: behave as a normal (non-first) run.
if (!cancelled) setFirstRun(false);
});
return () => {
cancelled = true;
};
}, [profile]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
{/* ── Header ── */}
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
<div className="flex items-baseline gap-2">
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
{shouldUseMock() ? "mock" : "tauri"}
</span>
</div>
<Toolbar justify="end">
<span className="text-xs text-faint">
{health ? (
<>
backend <span className="text-success"></span> v{health.version} ·{" "}
{events.length} events
</>
) : error ? (
<span className="text-danger">backend unavailable</span>
) : (
<span className="inline-flex items-center gap-1.5">
<Spinner size={12} /> checking
</span>
)}
</span>
{!firstRun && (
<Button
size="sm"
variant={showSettings ? "secondary" : "ghost"}
onClick={() => setShowSettings((v) => !v)}
>
{showSettings ? "Close settings" : "AI Profiles"}
</Button>
)}
</Toolbar>
</header>
{/* ── Body ── */}
<div className="flex flex-1 flex-col overflow-hidden">
{error && (
<Panel className="mx-4 mt-4 border-danger/40">
<p className="text-sm text-danger">Error: {error}</p>
</Panel>
)}
{firstRun ? (
// First launch: the wizard takes over the area, in a scrollable column
// (it can be taller than the viewport).
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-xl">
<FirstRunWizard onDone={() => setFirstRun(false)} />
</div>
</div>
) : showSettings ? (
// Settings is a full scrollable view (its "Configure profiles" reopens
// the wizard, which can also exceed the viewport height).
<div className="flex flex-1 justify-center overflow-y-auto p-6">
<div className="w-full max-w-2xl">
<ProfilesSettings />
</div>
</div>
) : (
// ProjectsView owns the full remaining area (its own IDE layout).
<ProjectsView />
)}
</div>
</div>
);
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -0,0 +1,110 @@
/**
* `Workspace` — the IDE shell rendered when at least one project tab is open
* (or when the launcher overlay is forced visible).
*
* Layout (full remaining height):
*
* ┌──────────────────────────────────────────────────────────┐
* │ PROJECT TABS [ alpha × ][ beta × ] [+] │
* ├────────────────┬─────────────────────────────────────────┤
* │ SIDEBAR ≈320px │ MAIN — LayoutGrid (fills remaining) │
* │ [Agents][Tmp.] │ │
* │ [Git] │ │
* │ panel content │ │
* └────────────────┴─────────────────────────────────────────┘
*
* The sidebar panel is selected via `sidebarTab`; the LayoutGrid fills the rest.
* All content is passed as render props / children; no domain logic here.
*/
import { cn } from "@/shared";
export type SidebarTab = "agents" | "templates" | "git";
interface WorkspaceProps {
/** Project tab bar (optional — hidden when no projects are open). */
projectTabBar?: React.ReactNode;
/** Whether to show the launcher overlay instead of the sidebar+main layout. */
showLauncher?: boolean;
/** The launcher component (shown when showLauncher is true). */
launcher?: React.ReactNode;
/** Currently-selected sidebar tab. */
sidebarTab: SidebarTab;
onSidebarTab: (tab: SidebarTab) => void;
/** Panel content for the sidebar body. */
agentsPanel: React.ReactNode;
templatesPanel: React.ReactNode;
gitPanel: React.ReactNode;
/** The main LayoutGrid area. */
layoutGrid: React.ReactNode;
}
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "git", label: "Git" },
];
export function Workspace({
projectTabBar,
showLauncher = false,
launcher,
sidebarTab,
onSidebarTab,
agentsPanel,
templatesPanel,
gitPanel,
layoutGrid,
}: WorkspaceProps) {
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* ── Project tab bar ── */}
{projectTabBar}
{showLauncher ? (
// No active project: show the centred launcher.
<div className="flex flex-1 flex-col overflow-auto">{launcher}</div>
) : (
// Active project: sidebar + main grid.
<div className="flex flex-1 overflow-hidden">
{/* ── Sidebar ── */}
<aside className="flex w-80 shrink-0 flex-col border-r border-border bg-surface">
{/* Sidebar tab strip */}
<div
className="flex shrink-0 border-b border-border"
>
{SIDEBAR_TABS.map((t) => (
<button
key={t.id}
type="button"
aria-selected={sidebarTab === t.id}
onClick={() => onSidebarTab(t.id)}
className={cn(
"flex-1 px-3 py-2 text-xs font-medium transition-colors",
sidebarTab === t.id
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
</div>
{/* Sidebar panel body */}
<div className="flex-1 overflow-auto p-2">
{sidebarTab === "agents" && agentsPanel}
{sidebarTab === "templates" && templatesPanel}
{sidebarTab === "git" && gitPanel}
</div>
</aside>
{/* ── Main: terminal grid ── */}
<main className="flex flex-1 flex-col overflow-hidden">
{layoutGrid}
</main>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,67 @@
/**
* L1 — the DI provider resolves real vs mock gateways from `VITE_USE_MOCK`,
* and `useGateways` exposes the injected set (and guards against misuse).
*/
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, renderHook } from "@testing-library/react";
import { MockSystemGateway } from "@/adapters/mock";
import { DIProvider, useGateways, resolveGateways, shouldUseMock } from "./di";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("shouldUseMock / resolveGateways", () => {
it("returns mock gateways when VITE_USE_MOCK=1", () => {
vi.stubEnv("VITE_USE_MOCK", "1");
expect(shouldUseMock()).toBe(true);
expect(resolveGateways().system).toBeInstanceOf(MockSystemGateway);
});
it("returns mock gateways when VITE_USE_MOCK=true", () => {
vi.stubEnv("VITE_USE_MOCK", "true");
expect(shouldUseMock()).toBe(true);
});
it("returns real (non-mock) gateways otherwise", () => {
vi.stubEnv("VITE_USE_MOCK", "");
expect(shouldUseMock()).toBe(false);
// Real adapter is not the mock implementation.
expect(resolveGateways().system).not.toBeInstanceOf(MockSystemGateway);
});
});
describe("DIProvider / useGateways", () => {
it("provides explicit gateways to consumers", () => {
const gateways = resolveGatewaysMock();
const { result } = renderHook(() => useGateways(), {
wrapper: ({ children }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
),
});
expect(result.current).toBe(gateways);
});
it("renders children inside the provider", () => {
render(
<DIProvider gateways={resolveGatewaysMock()}>
<span>ok</span>
</DIProvider>,
);
expect(screen.getByText("ok")).toBeTruthy();
});
it("throws when useGateways is used outside a provider", () => {
expect(() => renderHook(() => useGateways())).toThrow(
/must be used within a <DIProvider>/,
);
});
});
function resolveGatewaysMock() {
vi.stubEnv("VITE_USE_MOCK", "1");
const g = resolveGateways();
vi.unstubAllEnvs();
return g;
}

55
frontend/src/app/di.tsx Normal file
View File

@ -0,0 +1,55 @@
/**
* Dependency-injection provider for the UI ports.
*
* Chooses real Tauri adapters vs mocks based on `VITE_USE_MOCK` (any truthy
* value → mocks). Components consume gateways via {@link useGateways} and never
* construct adapters or call `invoke()` themselves.
*/
import {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
import type { Gateways } from "@/ports";
import { createTauriGateways } from "@/adapters";
import { createMockGateways } from "@/adapters/mock";
const GatewaysContext = createContext<Gateways | null>(null);
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
export function shouldUseMock(): boolean {
const flag = import.meta.env.VITE_USE_MOCK;
return flag === "1" || flag === "true";
}
/** Resolves the gateway set for the current environment. */
export function resolveGateways(): Gateways {
return shouldUseMock() ? createMockGateways() : createTauriGateways();
}
interface DIProviderProps {
children: ReactNode;
/** Optional explicit gateways (used by tests/Storybook). */
gateways?: Gateways;
}
export function DIProvider({ children, gateways }: DIProviderProps) {
const value = useMemo(() => gateways ?? resolveGateways(), [gateways]);
return (
<GatewaysContext.Provider value={value}>
{children}
</GatewaysContext.Provider>
);
}
/** Hook returning the injected gateways. Throws if used outside the provider. */
export function useGateways(): Gateways {
const ctx = useContext(GatewaysContext);
if (!ctx) {
throw new Error("useGateways must be used within a <DIProvider>");
}
return ctx;
}

21
frontend/src/app/main.tsx Normal file
View File

@ -0,0 +1,21 @@
/** React bootstrap: mounts the app inside the DI provider. */
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { DIProvider } from "./di";
import "@/shared/styles/theme.css";
const root = document.getElementById("root");
if (!root) {
throw new Error("missing #root element");
}
ReactDOM.createRoot(root).render(
<React.StrictMode>
<DIProvider>
<App />
</DIProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1,55 @@
/**
* L1 architecture guard: only `src/adapters` may import `@tauri-apps/api` or
* call `invoke()`. React components (features/app) must go through the gateway
* ports (ARCHITECTURE §1.3, L1 DoD "Aucun invoke() direct dans les composants").
*/
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, sep } from "node:path";
// Vitest runs with cwd = frontend/; scan the whole `src` tree from there.
const SRC = join(process.cwd(), "src");
/** Recursively collect .ts/.tsx files, skipping the allowed adapters dir. */
function collectSourceFiles(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
// `src/adapters` is the single allowed home of Tauri transport code.
if (full.endsWith(`${sep}adapters`)) continue;
collectSourceFiles(full, out);
} else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
out.push(full);
}
}
return out;
}
/** Strips block and line comments so doc-comment mentions don't false-positive. */
function stripComments(src: string): string {
return src
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\/\/[^\n]*/g, "");
}
describe("no direct Tauri usage outside src/adapters", () => {
const files = collectSourceFiles(SRC);
it("finds source files to scan", () => {
expect(files.length).toBeGreaterThan(0);
});
it("no file imports @tauri-apps/api", () => {
const offenders = files.filter((f) =>
stripComments(readFileSync(f, "utf8")).includes("@tauri-apps/api"),
);
expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]);
});
it("no file calls invoke( (ignoring comments)", () => {
const offenders = files.filter((f) =>
/\binvoke\s*\(/.test(stripComments(readFileSync(f, "utf8"))),
);
expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]);
});
});

View File

@ -0,0 +1,287 @@
/**
* Pure UI-domain types — TS mirrors of the backend DTOs (camelCase wire format,
* ARCHITECTURE §1.3). No React, no Tauri here: these are plain data shapes the
* ports speak in, so view logic and gateways stay testable in isolation.
*/
/** Health report returned by the `health` gateway/command. */
export interface HealthReport {
version: string;
alive: boolean;
timeMillis: number;
correlationId: string;
note: string | null;
}
/** A domain event relayed from the backend (tagged union on `type`). */
export type DomainEvent =
| { type: "projectCreated"; projectId: string }
| { type: "agentLaunched"; agentId: string; sessionId: string }
| { type: "agentExited"; agentId: string; code: number }
| { type: "templateUpdated"; templateId: string; version: number }
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
| { type: "agentSynced"; agentId: string; to: number }
| { type: "layoutChanged"; projectId: string }
| { type: "remoteConnected"; projectId: string }
| { type: "gitStateChanged"; projectId: string }
| { type: "ptyOutput"; sessionId: string; bytes: number[] };
/** Where a project physically lives (mirror of the backend `RemoteRef`). */
export type RemoteRef =
| { kind: "local" }
| {
kind: "ssh";
host: string;
port: number;
user: string;
auth: unknown;
remoteRoot: string;
}
| { kind: "wsl"; distro: string };
/** A project as returned by the `project` gateway (mirror of `ProjectDto`). */
export interface Project {
id: string;
name: string;
root: string;
remote: RemoteRef;
createdAt: number;
}
/** Stable error shape mirrored from the backend `ErrorDto`. */
export interface GatewayError {
code: string;
message: string;
}
// ---------------------------------------------------------------------------
// Layout (L4) — mirror of the domain `LayoutTree` (ARCHITECTURE §3, §7).
// ---------------------------------------------------------------------------
/** Split direction: `row` = columns (left→right), `column` = rows (top→bottom). */
export type Direction = "row" | "column";
/** A terminal-hosting leaf cell. `session` is the hosted SessionId, if any.
* `agent` is the agent id if an agent is pinned to this cell (absent = plain terminal). */
export interface LeafCell {
id: string;
session?: string | null;
agent?: string;
}
/** A weighted child of a split. `weight` is a relative (`> 0`) share. */
export interface WeightedChild {
node: LayoutNode;
weight: number;
}
/** A weighted n-ary split (rows or columns). */
export interface SplitContainer {
id: string;
direction: Direction;
children: WeightedChild[];
}
/** A spreadsheet grid cell placement with spans. */
export interface GridCell {
node: LayoutNode;
row: number;
col: number;
rowSpan: number;
colSpan: number;
}
/** A spreadsheet-style grid with per-row/col weights and span-based merging. */
export interface GridContainer {
id: string;
colWeights: number[];
rowWeights: number[];
cells: GridCell[];
}
/**
* A node in the layout tree. Tagged on `type` with the payload under `node`,
* matching the backend `#[serde(tag = "type", content = "node")]`.
*/
export type LayoutNode =
| { type: "leaf"; node: LeafCell }
| { type: "split"; node: SplitContainer }
| { type: "grid"; node: GridContainer };
/** The root of a tab's terminal layout. */
export interface LayoutTree {
root: LayoutNode;
}
/**
* A layout mutation (mirror of the backend `LayoutOperationDto`, tagged on
* `type`). Node/session ids are UUID strings.
*/
export type LayoutOperation =
| {
type: "split";
target: string;
direction: Direction;
newLeaf: string;
container: string;
}
| { type: "merge"; container: string; keepIndex: number }
| { type: "resize"; container: string; weights: number[] }
| { type: "move"; from: string; to: string }
| { type: "setSession"; target: string; session?: string | null }
| { type: "setCellAgent"; target: string; agent: string | null };
/** The kind of a named layout. */
export type LayoutKind = "terminal" | "gitGraph";
/** Named layout entry returned by `listLayouts`. */
export interface LayoutInfo {
id: string;
name: string;
kind: LayoutKind;
}
/**
* A commit node in the full git graph (DAG). `refs` carries short branch/tag
* names (e.g. `"main"`, `"tag: v1.0"`). `timestamp` is seconds since Unix epoch.
*/
export interface GraphCommit {
hash: string;
summary: string;
parents: string[];
refs: string[];
author: string;
timestamp: number;
}
/** Response of `listLayouts`. */
export interface LayoutList {
layouts: LayoutInfo[];
activeId: string;
}
/** Unsubscribe handle returned by event subscriptions. */
export type Unsubscribe = () => void;
// ---------------------------------------------------------------------------
// AI profiles & first-run (L5) — mirror of the domain `AgentProfile` /
// `ContextInjection` (CONTEXT §9, ARCHITECTURE §3).
// ---------------------------------------------------------------------------
/**
* Context-injection strategy (tagged on `strategy`, mirror of the backend
* `ContextInjection`):
* - `conventionFile`: write the `.md` to a conventional file (e.g. `CLAUDE.md`),
* - `flag`: pass the context path through a CLI flag,
* - `stdin`: pipe the content on stdin,
* - `env`: pass the context via an environment variable.
*/
export type ContextInjection =
| { strategy: "conventionFile"; target: string }
| { strategy: "flag"; flag: string }
| { strategy: "stdin" }
| { strategy: "env"; var: string };
/** The four injection strategy discriminants. */
export type InjectionStrategy = ContextInjection["strategy"];
/**
* A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a
* UUID string; `detect` is the optional detection command line.
*/
export interface AgentProfile {
id: string;
name: string;
command: string;
args: string[];
contextInjection: ContextInjection;
detect: string | null;
cwdTemplate: string;
}
/** Availability of a candidate profile after detection (mirror of the DTO). */
export interface ProfileAvailability {
profile: AgentProfile;
available: boolean;
}
/** First-run state (mirror of `FirstRunStateDto`). */
export interface FirstRunState {
isFirstRun: boolean;
referenceProfiles: AgentProfile[];
}
// ---------------------------------------------------------------------------
// Agents (L6) — mirror of the domain `Agent` / `AgentOrigin` (ARCHITECTURE §6).
// ---------------------------------------------------------------------------
/**
* Origin of an agent (tagged on `type`, mirror of the backend `AgentOrigin`):
* - `scratch`: created from scratch, no template link,
* - `fromTemplate`: derived from a template, tracking the last synced version.
*/
export type AgentOrigin =
| { type: "scratch" }
| { type: "fromTemplate"; templateId: string; syncedTemplateVersion: number };
/**
* A project-scoped agent (mirror of the backend `Agent` DTO, camelCase wire
* format). `contextPath` is the relative path of the agent's `.md` within
* `.ideai/` (e.g. `agents/foo.md`).
*/
export interface Agent {
id: string;
name: string;
contextPath: string;
profileId: string;
origin: AgentOrigin;
synchronized: boolean;
}
// ---------------------------------------------------------------------------
// Templates (L7) — mirror of the domain `Template` / `AgentDrift`.
// ---------------------------------------------------------------------------
/**
* A reusable agent template (mirror of the backend `Template` DTO).
* `version` is incremented on each `updateTemplate` call.
*/
export interface Template {
id: string;
name: string;
contentMd: string;
version: number;
defaultProfileId: string;
}
/**
* Drift between a synchronized agent's last-synced template version and the
* template's current version (mirror of `AgentDrift` backend DTO).
*/
export interface AgentDrift {
agentId: string;
from: number;
to: number;
}
// ---------------------------------------------------------------------------
// Git (L8) — UI-domain types for the git feature.
// ---------------------------------------------------------------------------
/** A file in the working tree with its staging state. */
export interface GitFileStatus {
path: string;
staged: boolean;
}
/** A git commit summary (short representation). */
export interface GitCommit {
hash: string;
summary: string;
}
/** The list of branches and the currently checked-out branch. */
export interface GitBranches {
branches: string[];
current: string | null;
}

View File

@ -0,0 +1,3 @@
# Feature modules land here per lot: projects, agents, templates, terminals,
# layout, git, remote, first-run (ARCHITECTURE §10). Each as
# <feature>/{components,hooks,store,index.ts}.

View File

@ -0,0 +1,373 @@
/**
* `AgentsPanel` — feature component for the agents panel (L6).
*
* Pure presentation: all behaviour comes from {@link useAgents}. Styled with
* `@/shared` design system tokens; no inline styles, no classes invented outside
* the theme.
*
* When the user clicks Launch, an agent terminal is mounted below the agent
* list using a {@link TerminalView} with a custom `open` prop that delegates
* to `agent.launchAgent`. A Stop button unmounts it.
*
* Feature #3: the create form includes a template selector. Choosing a template
* calls `createAgentFromTemplate`; leaving it at "(none / from scratch)" uses
* the existing `createAgent` path with the selected profile.
*/
import { useState } from "react";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { TerminalView } from "@/features/terminals/TerminalView";
import { useDrift } from "@/features/templates/useDrift";
import { useGateways } from "@/app/di";
import { useAgents } from "./useAgents";
export interface AgentsPanelProps {
/** The project whose agents to manage. */
projectId: string;
/**
* The filesystem root of the project. Passed as the `cwd` to the agent
* terminal. Supplied by `ProjectsView` which has `active.root`.
*/
projectRoot?: string;
}
export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
const vm = useAgents(projectId);
const drift = useDrift(projectId);
const gateways = useGateways();
const templateGw = gateways.template ?? null;
// Create form state
const [newName, setNewName] = useState("");
const [newProfileId, setNewProfileId] = useState("");
const [newTemplateId, setNewTemplateId] = useState("");
// Templates available for the selector (loaded lazily on first render via a
// separate effect handled inline via hook state)
const [templates, setTemplates] = useState<import("@/domain").Template[]>([]);
const [templatesLoaded, setTemplatesLoaded] = useState(false);
// Load templates once on mount (best-effort — if it fails the selector just stays empty)
if (!templatesLoaded && templateGw) {
setTemplatesLoaded(true);
void templateGw.listTemplates().then(setTemplates).catch(() => {});
}
// Context editor state — local copy before Save
const [editedContext, setEditedContext] = useState(vm.context);
// When the hook loads the context for a newly selected agent, mirror it.
// We use a derived check: if the hook's context changed externally we reset.
const [lastLoadedContext, setLastLoadedContext] = useState(vm.context);
if (vm.context !== lastLoadedContext) {
setLastLoadedContext(vm.context);
setEditedContext(vm.context);
}
/**
* Id of the agent currently being launched / running in the terminal pane.
* Kept in local state so the terminal container can appear immediately when
* the user clicks Launch (before the gateway resolves). vm.runningAgentId is
* set by the hook once launchAgent resolves.
*/
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
const canCreate = newName.trim().length > 0 && !vm.busy;
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!canCreate) return;
if (newTemplateId && templateGw) {
// Create from template — the template imposes its default profile.
await templateGw.createAgentFromTemplate(projectId, newTemplateId, {
name: newName.trim(),
synchronized: true,
});
// Refresh the agents list since we bypassed the hook's createAgent path.
await vm.refresh();
} else {
const profileId = newProfileId.trim() || "";
await vm.createAgent(newName.trim(), profileId);
}
setNewName("");
setNewProfileId("");
setNewTemplateId("");
}
function handleLaunch(agentId: string) {
setActiveAgentId(agentId);
}
function handleStop() {
vm.stopAgent();
setActiveAgentId(null);
}
const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null;
// Determine if a template is chosen → profile selector is hidden (template imposes it).
const hasTemplate = newTemplateId !== "";
return (
<Panel title="Agents" className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Create form ── */}
<form
onSubmit={(e) => void handleCreate(e)}
className="flex flex-wrap items-end gap-2 border-b border-border p-4"
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="agent-name-input"
className="text-xs font-medium text-muted"
>
Name
</label>
<Input
id="agent-name-input"
aria-label="agent name"
placeholder="My agent"
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="min-w-32"
/>
</div>
{/* Template selector */}
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="agent-template-select"
className="text-xs font-medium text-muted"
>
Template
</label>
<select
id="agent-template-select"
aria-label="agent template"
value={newTemplateId}
onChange={(e) => setNewTemplateId(e.target.value)}
className={cn(
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
disabled={vm.busy}
>
<option value="">(none / from scratch)</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{/* Profile selector — hidden when a template is chosen */}
{!hasTemplate && (
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="agent-profile-select"
className="text-xs font-medium text-muted"
>
Profile
{vm.profiles.length === 0 && (
<span className="ml-1 text-faint">(no profiles configured you can still enter an id)</span>
)}
</label>
{vm.profiles.length > 0 ? (
<select
id="agent-profile-select"
aria-label="agent profile"
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className={cn(
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value=""> select profile </option>
{vm.profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
) : (
<Input
id="agent-profile-select"
aria-label="agent profile"
placeholder="profile-id (optional)"
value={newProfileId}
onChange={(e) => setNewProfileId(e.target.value)}
className="min-w-32"
/>
)}
</div>
)}
<Button
type="submit"
variant="primary"
aria-label="create agent"
disabled={!canCreate}
loading={vm.busy}
>
Create
</Button>
</form>
{/* ── Agent list ── */}
<div className="p-4">
{vm.agents.length === 0 ? (
<p className="text-sm text-muted">No agents yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.agents.map((a) => {
const isSelected = a.id === vm.selectedAgentId;
const isRunning = a.id === activeAgentId;
const profileName =
vm.profiles.find((p) => p.id === a.profileId)?.name ??
a.profileId;
const agentDrift = drift.driftByAgentId.get(a.id);
return (
<li
key={a.id}
className={cn(
"flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0",
isSelected && "rounded-md bg-raised px-2",
)}
>
<button
type="button"
onClick={() => void vm.selectAgent(a.id)}
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left"
aria-pressed={isSelected}
>
<span className="flex items-center gap-2">
<span className="font-medium text-content">{a.name}</span>
{agentDrift && (
<span
aria-label="update available"
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
update available
</span>
)}
</span>
<span className="text-xs text-muted">{profileName}</span>
</button>
<div className="flex items-center gap-1.5 shrink-0">
{agentDrift && (
<Button
size="sm"
variant="primary"
aria-label={`sync ${a.name}`}
disabled={vm.busy || drift.driftBusy}
onClick={() =>
void drift.syncAgent(a.id, vm.refresh)
}
>
Sync
</Button>
)}
{isRunning ? (
<Button
size="sm"
variant="ghost"
aria-label={`stop ${a.name}`}
onClick={handleStop}
className="text-danger hover:text-danger"
>
Stop
</Button>
) : (
<Button
size="sm"
variant="primary"
aria-label={`launch ${a.name}`}
disabled={vm.busy || activeAgentId !== null}
onClick={() => handleLaunch(a.id)}
>
Launch
</Button>
)}
<Button
size="sm"
variant="ghost"
aria-label={`delete ${a.name}`}
disabled={vm.busy}
onClick={() => void vm.deleteAgent(a.id)}
className="text-danger hover:text-danger"
>
Delete
</Button>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* ── Agent terminal ── */}
{activeAgentId !== null && (
<div className="border-t border-border p-4">
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
<TerminalView
cwd={projectRoot}
open={(opts, onData) =>
vm.launchAgent(activeAgentId, opts, onData)
}
/>
</div>
</div>
)}
{/* ── Context editor ── */}
{selectedAgent && (
<div className="flex flex-col gap-2 border-t border-border p-4">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-content">
Context {selectedAgent.name}
</h4>
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
</div>
<textarea
aria-label="agent context"
value={editedContext}
onChange={(e) => setEditedContext(e.target.value)}
rows={10}
className={cn(
"w-full rounded-md bg-raised px-3 py-2 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50 resize-y font-mono",
)}
disabled={vm.busy}
/>
<div className="flex items-center justify-end gap-2">
{vm.busy && <Spinner size={14} />}
<Button
variant="primary"
disabled={vm.busy}
onClick={() => void vm.saveContext(editedContext)}
>
Save
</Button>
</div>
</div>
)}
</Panel>
);
}

View File

@ -0,0 +1,386 @@
/**
* L6 — agents feature wired to the stateful `MockAgentGateway` (and a seeded
* `MockProfileGateway`) via the real `DIProvider`.
*
* Covers:
* - create → agent appears in list
* - select → context is shown; edit + save → re-reading shows new content
* - delete → agent removed from list
* - launch → `launchAgent` is called and mounts a terminal view; stop unmounts it
* - empty name → Create button disabled
* - MockAgentGateway unit behaviour: slug deduplication, NOT_FOUND errors
*/
import { describe, it, expect, vi } from "vitest";
import {
render,
screen,
waitFor,
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { AgentsPanel } from "./AgentsPanel";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const PROJECT_ID = "proj-test-001";
/**
* Renders `AgentsPanel` behind a `DIProvider` with an isolated
* `MockAgentGateway` (and optionally a seeded `MockProfileGateway`).
*/
function renderPanel(
agent: MockAgentGateway = new MockAgentGateway(),
profile: MockProfileGateway = new MockProfileGateway(),
projectRoot = "/home/me/proj",
template?: MockTemplateGateway,
) {
const tmpl = template ?? new MockTemplateGateway(agent);
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
return {
agent,
profile,
template: tmpl,
...render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
</DIProvider>,
),
};
}
/** Waits until no Spinner is visible (busy → idle). */
async function waitForIdle() {
// The Create button is enabled when not busy and name is non-empty.
// We wait for the panel to stabilise by waiting for "No agents yet." or
// for the agent list to be rendered.
await waitFor(() => {
// Panel has finished its initial load when the form is accessible.
expect(screen.getByLabelText("agent name")).toBeTruthy();
});
}
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
async function createAgent(name: string, profileId?: string) {
await waitForIdle();
fireEvent.change(screen.getByLabelText("agent name"), {
target: { value: name },
});
if (profileId) {
const profileInput = screen.queryByLabelText("agent profile");
if (profileInput) {
fireEvent.change(profileInput, { target: { value: profileId } });
}
}
const createBtn = screen.getByRole("button", { name: "create agent" });
expect((createBtn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(createBtn);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AgentsPanel (with MockAgentGateway)", () => {
it("shows 'No agents yet.' when the project has no agents", async () => {
renderPanel();
await waitForIdle();
expect(screen.getByText("No agents yet.")).toBeTruthy();
});
it("creating an agent adds it to the list", async () => {
renderPanel();
await createAgent("My Worker");
const agent = await screen.findByText("My Worker");
expect(agent).toBeTruthy();
});
it("the Create button is disabled when the name is empty", async () => {
renderPanel();
await waitForIdle();
// Name field is empty by default → disabled
const btn = screen.getByRole("button", { name: "create agent" });
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("selecting an agent displays its context", async () => {
const agent = new MockAgentGateway();
// Pre-seed an agent with initial content.
await agent.createAgent(PROJECT_ID, {
name: "Alpha",
profileId: "p1",
initialContent: "## Alpha context",
});
renderPanel(agent);
await waitForIdle();
// Click on the agent row button (aria-pressed) to select it — not the
// "launch Alpha" action button.
const buttons = screen.getAllByRole("button", { name: /alpha/i });
const rowBtn = buttons.find(
(b) => b.hasAttribute("aria-pressed"),
)!;
fireEvent.click(rowBtn);
const textarea = await screen.findByLabelText("agent context");
expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
});
it("editing and saving context persists the new content", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Beta",
profileId: "p1",
initialContent: "initial",
});
renderPanel(agent);
await waitForIdle();
// Select the agent row button (aria-pressed), not the action buttons.
const buttons = screen.getAllByRole("button", { name: /beta/i });
const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
fireEvent.click(rowBtn);
const textarea = await screen.findByLabelText("agent context");
fireEvent.change(textarea, { target: { value: "updated content" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
// After save the gateway should hold the new content.
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
const ctx = await agent.readContext(PROJECT_ID, agents[0].id);
expect(ctx).toBe("updated content");
});
});
it("deleting an agent removes it from the list", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, {
name: "Gamma",
profileId: "p1",
});
renderPanel(agent);
await waitForIdle();
await screen.findByText("Gamma");
fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));
await waitFor(() => {
expect(screen.queryByText("Gamma")).toBeNull();
});
// Gateway-level check.
const remaining = await agent.listAgents(PROJECT_ID);
expect(remaining).toHaveLength(0);
});
it("launching an agent calls launchAgent and mounts the terminal view", async () => {
const agent = new MockAgentGateway();
const createdAgent = await agent.createAgent(PROJECT_ID, {
name: "Delta",
profileId: "p1",
});
// Spy on launchAgent.
const launchSpy = vi.spyOn(agent, "launchAgent");
renderPanel(agent);
await waitForIdle();
fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));
// The terminal container div (data-testid="terminal-view") should be mounted.
await waitFor(() => {
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
// launchAgent should have been called with the correct projectId and agentId.
await waitFor(() => {
if (launchSpy.mock.calls.length > 0) {
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
expect(launchSpy.mock.calls[0][1]).toBe(createdAgent.id);
// options (rows/cols) and onData callback
expect(typeof launchSpy.mock.calls[0][2]).toBe("object");
expect(typeof launchSpy.mock.calls[0][3]).toBe("function");
}
});
});
it("stopping an agent unmounts the terminal view", async () => {
const agent = new MockAgentGateway();
await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
renderPanel(agent);
await waitForIdle();
// Launch
fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
await waitFor(() => {
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
// Stop
fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
await waitFor(() => {
expect(screen.queryByTestId("terminal-view")).toBeNull();
});
});
it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
// Seed a template so the selector has something to choose
const t = await tmpl.createTemplate({
name: "My Template",
content: "# ctx",
defaultProfileId: "p1",
});
const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate");
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
await waitForIdle();
// Select the template in the dropdown
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
fireEvent.change(templateSelect, { target: { value: t.id } });
// Fill agent name
fireEvent.change(screen.getByLabelText("agent name"), {
target: { value: "From Template" },
});
// Click Create
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
// createAgentFromTemplate should have been called with the correct args
await waitFor(() => {
expect(createFromTemplateSpy).toHaveBeenCalledWith(
PROJECT_ID,
t.id,
{ name: "From Template", synchronized: true },
);
});
// Agent should appear in the list
await screen.findByText("From Template");
});
it("shows profiles in the dropdown when profiles are configured", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([
{
id: "prof-1",
name: "Claude Code",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: null,
cwdTemplate: "{projectRoot}",
},
]);
renderPanel(new MockAgentGateway(), profile);
await waitForIdle();
// The select element should contain the profile option.
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.text);
expect(options).toContain("Claude Code");
});
});
// ---------------------------------------------------------------------------
// MockAgentGateway unit tests
// ---------------------------------------------------------------------------
/** Helper: minimal valid options for launchAgent. */
const LAUNCH_OPTS = { cwd: "/tmp", rows: 24, cols: 80 };
const NOOP_ON_DATA = (_bytes: Uint8Array) => {};
describe("MockAgentGateway (unit)", () => {
it("creates agents with unique slugified contextPaths", async () => {
const gw = new MockAgentGateway();
const a1 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
const a2 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
const a3 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
expect(a1.contextPath).toBe("agents/my-agent.md");
expect(a2.contextPath).toBe("agents/my-agent-2.md");
expect(a3.contextPath).toBe("agents/my-agent-3.md");
});
it("readContext throws NOT_FOUND for unknown agent", async () => {
const gw = new MockAgentGateway();
await expect(gw.readContext("proj", "ghost-id")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("deleteAgent throws NOT_FOUND for unknown agent", async () => {
const gw = new MockAgentGateway();
await expect(gw.deleteAgent("proj", "ghost-id")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("launchAgent returns a TerminalHandle with sequential session ids", async () => {
const gw = new MockAgentGateway();
const a1 = await gw.createAgent("proj", { name: "A1", profileId: "p" });
const a2 = await gw.createAgent("proj", { name: "A2", profileId: "p" });
const h1 = await gw.launchAgent("proj", a1.id, LAUNCH_OPTS, NOOP_ON_DATA);
const h2 = await gw.launchAgent("proj", a2.id, LAUNCH_OPTS, NOOP_ON_DATA);
expect(h1.sessionId).toBe("mock-agent-session-1");
expect(h2.sessionId).toBe("mock-agent-session-2");
});
it("launchAgent throws NOT_FOUND for an agent not in the project", async () => {
const gw = new MockAgentGateway();
await expect(
gw.launchAgent("proj", "unknown-id", LAUNCH_OPTS, NOOP_ON_DATA),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("launchAgent greeting is delivered via onData", async () => {
const gw = new MockAgentGateway();
const a = await gw.createAgent("proj", { name: "Greeter", profileId: "p" });
const received: Uint8Array[] = [];
await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes));
// Wait for the queueMicrotask greeting
await new Promise((r) => setTimeout(r, 0));
const dec = new TextDecoder();
const msg = received.map((b) => dec.decode(b)).join("");
expect(msg).toContain(`agent ${a.id}`);
});
it("launchAgent handle write echoes bytes back via onData", async () => {
const gw = new MockAgentGateway();
const a = await gw.createAgent("proj", { name: "Echo", profileId: "p" });
const received: Uint8Array[] = [];
const handle = await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) =>
received.push(bytes),
);
await handle.write(new TextEncoder().encode("hi"));
const dec = new TextDecoder();
const echoed = received.map((b) => dec.decode(b)).join("");
// The greeting (via queueMicrotask) and the echo both arrive; just check echo is there
expect(echoed).toContain("hi");
});
it("projects are isolated — agents in one project don't appear in another", async () => {
const gw = new MockAgentGateway();
await gw.createAgent("proj-A", { name: "A Agent", profileId: "p" });
const listB = await gw.listAgents("proj-B");
expect(listB).toHaveLength(0);
});
});

View File

@ -0,0 +1,8 @@
/**
* Agents feature — public surface (L6).
*/
export { AgentsPanel } from "./AgentsPanel";
export type { AgentsPanelProps } from "./AgentsPanel";
export { useAgents } from "./useAgents";
export type { AgentsViewModel } from "./useAgents";

View File

@ -0,0 +1,211 @@
/**
* `useAgents` — view-model hook for the agents feature (L6).
*
* Owns the agents feature state for a given project and exposes the actions the
* UI triggers. Consumes {@link AgentGateway} and {@link ProfileGateway}
* exclusively; never touches `invoke()` or `@tauri-apps/api`, keeping the
* component layer testable with mock gateways (ARCHITECTURE §1.3).
*/
import { useCallback, useEffect, useState } from "react";
import type { Agent, AgentProfile, GatewayError } from "@/domain";
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
import { useGateways } from "@/app/di";
/** What the agents UI needs from this hook. */
export interface AgentsViewModel {
/** All agents known to the project. */
agents: Agent[];
/** Id of the currently selected agent, or `null`. */
selectedAgentId: string | null;
/** The `.md` context of the selected agent (loaded on select). */
context: string;
/** Profiles available for assigning to a new agent. */
profiles: AgentProfile[];
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/**
* Id of the agent whose terminal is currently running, or `null`.
* Set when the user clicks Launch; cleared when the terminal is stopped.
*/
runningAgentId: string | null;
/** Reloads the agent list. */
refresh: () => Promise<void>;
/** Creates a new agent and refreshes the list. */
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
/** Selects an agent and loads its context. */
selectAgent: (agentId: string) => Promise<void>;
/** Saves the context for the currently selected agent. */
saveContext: (content: string) => Promise<void>;
/** Deletes an agent; deselects if it was selected. */
deleteAgent: (agentId: string) => Promise<void>;
/**
* Returns an opener compatible with `TerminalView`'s `open` prop for the
* given agent. Calling it sets `runningAgentId`. The component unmounting
* will call `handle.close()` automatically via the TerminalView cleanup.
*/
launchAgent: (
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
/** Clears `runningAgentId` (called by the Stop button to unmount the terminal). */
stopAgent: () => 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 useAgents(projectId: string): AgentsViewModel {
const { agent, profile } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [context, setContext] = useState<string>("");
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const [agentList, profileList] = await Promise.all([
agent.listAgents(projectId),
profile.listProfiles(),
]);
setAgents(agentList);
setProfiles(profileList);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [agent, profile, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
setBusy(true);
setError(null);
try {
const created = await agent.createAgent(projectId, {
name,
profileId,
initialContent,
});
setAgents((prev) => [...prev, created]);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const selectAgent = useCallback(
async (agentId: string) => {
setBusy(true);
setError(null);
try {
const ctx = await agent.readContext(projectId, agentId);
setSelectedAgentId(agentId);
setContext(ctx);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const saveContext = useCallback(
async (content: string) => {
if (!selectedAgentId) return;
setBusy(true);
setError(null);
try {
await agent.updateContext(projectId, selectedAgentId, content);
setContext(content);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId, selectedAgentId],
);
const deleteAgent = useCallback(
async (agentId: string) => {
setBusy(true);
setError(null);
try {
await agent.deleteAgent(projectId, agentId);
setAgents((prev) => prev.filter((a) => a.id !== agentId));
setSelectedAgentId((current) => (current === agentId ? null : current));
if (selectedAgentId === agentId) setContext("");
// Also stop the terminal if the running agent was just deleted.
setRunningAgentId((current) => (current === agentId ? null : current));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId, selectedAgentId],
);
const launchAgent = useCallback(
async (
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> => {
setError(null);
try {
const handle = await agent.launchAgent(projectId, agentId, options, onData);
setRunningAgentId(agentId);
return handle;
} catch (e) {
setError(describe(e));
throw e;
}
},
[agent, projectId],
);
const stopAgent = useCallback(() => {
setRunningAgentId(null);
}, []);
return {
agents,
selectedAgentId,
context,
profiles,
error,
busy,
runningAgentId,
refresh,
createAgent,
selectAgent,
saveContext,
deleteAgent,
launchAgent,
stopAgent,
};
}

View File

@ -0,0 +1,227 @@
/**
* L5 — the first-run wizard wired to the stateful {@link MockProfileGateway} via
* the real {@link DIProvider}. Covers: pre-filled editable rows, detection ✓/✗,
* adding a custom profile, and finishing (configure ⇒ first run closed ⇒ onDone).
*/
import { describe, it, expect, vi } from "vitest";
import {
render,
screen,
within,
waitFor,
fireEvent,
} from "@testing-library/react";
import { MockProfileGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard";
function renderWizard(
profile: MockProfileGateway = new MockProfileGateway(),
onDone = vi.fn(),
) {
const gateways = { profile } as unknown as Gateways;
return {
profile,
onDone,
...render(
<DIProvider gateways={gateways}>
<FirstRunWizard onDone={onDone} />
</DIProvider>,
),
};
}
async function waitForLoaded() {
await screen.findByLabelText("first run setup");
}
describe("FirstRunWizard (with MockProfileGateway)", () => {
it("renders the four pre-filled, editable reference profiles", async () => {
renderWizard();
await waitForLoaded();
for (const name of ["Claude Code", "OpenAI Codex CLI", "Gemini CLI", "Aider"]) {
expect(screen.getByText(name)).toBeTruthy();
}
// Commands are editable inputs, pre-filled.
const claudeCmd = screen.getByLabelText(
"Claude Code command",
) as HTMLInputElement;
expect(claudeCmd.value).toBe("claude");
});
it("editing a command updates the input value", async () => {
renderWizard();
await waitForLoaded();
const cmd = screen.getByLabelText("Aider command") as HTMLInputElement;
fireEvent.change(cmd, { target: { value: "aider-2" } });
expect(cmd.value).toBe("aider-2");
});
it("detection shows ✓ for claude and ✗ for the rest", async () => {
renderWizard();
await waitForLoaded();
fireEvent.click(screen.getByRole("button", { name: "Detect installed CLIs" }));
await waitFor(() => {
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/);
});
expect(screen.getByLabelText("Aider availability").textContent).toMatch(
/not found/,
);
});
it("adds a valid custom profile as a new row", async () => {
renderWizard();
await waitForLoaded();
const form = screen.getByLabelText("add custom profile");
fireEvent.change(within(form).getByLabelText("custom name"), {
target: { value: "My AI" },
});
fireEvent.change(within(form).getByLabelText("custom command"), {
target: { value: "my-ai" },
});
fireEvent.click(
within(form).getByRole("button", { name: "Add custom profile" }),
);
expect(await screen.findByText("My AI")).toBeTruthy();
// The custom command input now exists as a row.
expect((screen.getByLabelText("My AI command") as HTMLInputElement).value).toBe(
"my-ai",
);
});
it("add button is disabled until the custom draft is valid", async () => {
renderWizard();
await waitForLoaded();
const form = screen.getByLabelText("add custom profile");
const addBtn = within(form).getByRole("button", {
name: "Add custom profile",
}) as HTMLButtonElement;
expect(addBtn.disabled).toBe(true);
fireEvent.change(within(form).getByLabelText("custom name"), {
target: { value: "X" },
});
fireEvent.change(within(form).getByLabelText("custom command"), {
target: { value: "x" },
});
expect(addBtn.disabled).toBe(false);
});
it("auto-detects on open and pre-checks only installed CLIs", async () => {
renderWizard();
await waitForLoaded();
// The mock reports only `claude` as installed → only it is pre-checked.
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
expect(
(screen.getByLabelText("use OpenAI Codex CLI") as HTMLInputElement).checked,
).toBe(false);
expect(
(screen.getByLabelText("use Aider") as HTMLInputElement).checked,
).toBe(false);
// Availability was filled automatically, without clicking the button.
expect(
screen.getByLabelText("Claude Code availability").textContent,
).toMatch(/installed/);
});
it("finishing persists the auto-selected (installed) profiles and closes the first run", async () => {
const { profile, onDone } = renderWizard();
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(() => expect(onDone).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
);
// Only the installed CLI was pre-selected, so only it is persisted.
const saved = await profile.listProfiles();
expect(saved.map((p) => p.command)).toEqual(["claude"]);
expect((await profile.firstRunState()).isFirstRun).toBe(false);
});
it("ticking an extra profile persists it alongside the installed ones", async () => {
const { profile } = renderWizard();
await waitForLoaded();
await waitFor(() =>
expect(
(screen.getByLabelText("use Claude Code") as HTMLInputElement).checked,
).toBe(true),
);
// Keep Aider too (not installed, unchecked by default).
fireEvent.click(screen.getByLabelText("use Aider"));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
expect(saved.map((p) => p.command)).toEqual(["claude", "aider"]);
});
});
it("renders nothing when it is not the first run", async () => {
const profile = new MockProfileGateway();
await profile.configureProfiles([]); // closes first run
const { container } = renderWizard(profile);
// No wizard section ever appears.
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
);
expect(container.querySelector("section")).toBeNull();
});
});
describe("FirstRunWizard reopening after the first run (forceOpen)", () => {
/** A gateway whose first run is already done (profiles configured). */
async function configuredGateway() {
const profile = new MockProfileGateway();
await profile.configureProfiles([]); // marks first run as done
return profile;
}
it("stays hidden once the first run is done (default)", async () => {
const profile = await configuredGateway();
const gateways = { profile } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<FirstRunWizard />
</DIProvider>,
);
// Give the async first-run state time to resolve to `false`.
await waitFor(() =>
expect(screen.queryByLabelText("first run setup")).toBeNull(),
);
});
it("renders when forced open (Settings ▸ Configure profiles)", async () => {
const profile = await configuredGateway();
const gateways = { profile } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<FirstRunWizard forceOpen />
</DIProvider>,
);
expect(await screen.findByLabelText("first run setup")).toBeTruthy();
});
});

View File

@ -0,0 +1,304 @@
/**
* First-run wizard (L5). Shown on the very first IDE launch: it offers the
* pre-filled reference profiles (Claude/Codex/Gemini/Aider) with **editable**
* commands, lets the user detect which CLIs are installed (✓/✗), add a **custom**
* profile, then saves the chosen profiles and closes the first run.
*
* Pure presentation: all behaviour comes from {@link useFirstRun} (the
* {@link ProfileGateway} port). Custom-profile validation is the pure logic in
* `./profile`.
*/
import { useState } from "react";
import type { AgentProfile, InjectionStrategy } from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultInjection,
emptyCustomProfile,
isProfileValid,
parseArgs,
validateProfile,
} from "./profile";
/** Shared classes for the native `<select>` so it matches the Input control. */
const SELECT_CLASS =
"h-9 w-full rounded-md border border-border bg-raised px-3 text-sm text-content " +
"outline-none focus:border-primary";
/** A small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
/**
* Renders the wizard when it is the first run. Calls `onDone` once the user
* finishes (so the host can drop the wizard and show the normal UI). Returns
* Returns `null` while loading. By default it also returns `null` once the first
* run is done (auto-show path in {@link App}); pass `forceOpen` to render it
* regardless — used by "Settings ▸ Configure profiles" to reopen the wizard after
* the first run.
*/
export function FirstRunWizard({
onDone,
forceOpen = false,
}: {
onDone?: () => void;
/** Render the wizard even when it is no longer the first run. */
forceOpen?: boolean;
}) {
const vm = useFirstRun();
if (vm.isFirstRun === null) return null;
if (!forceOpen && vm.isFirstRun === false) return null;
async function finish() {
await vm.finish();
onDone?.();
}
return (
<Panel
aria-label="first run setup"
className="border-primary/40"
title={
<div className="flex flex-col">
<h2 className="text-base font-semibold text-content">Welcome to IdeA</h2>
<p className="text-sm text-muted">
Choose which AI CLIs to configure. Commands are pre-filled and
editable; you can also add your own.
</p>
</div>
}
>
<div className="flex flex-col gap-4">
{vm.error && (
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
)}
<Toolbar>
<Button
onClick={() => void vm.detect()}
disabled={vm.busy}
className="whitespace-nowrap"
>
Detect installed CLIs
</Button>
</Toolbar>
<ul className="flex list-none flex-col gap-3 p-0">
{vm.entries.map((entry) => (
<ProfileRow
key={entry.profile.id}
entry={entry}
onToggle={() => vm.toggle(entry.profile.id)}
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
onRemove={() => vm.remove(entry.profile.id)}
/>
))}
</ul>
<AddCustomProfile onAdd={(p) => vm.addCustom(p)} />
<footer className="flex gap-2">
<Button variant="primary" onClick={() => void finish()} disabled={vm.busy}>
Save and continue
</Button>
</footer>
</div>
</Panel>
);
}
/** One editable candidate row: select, edit command/args, see availability. */
function ProfileRow({
entry,
onToggle,
onChange,
onRemove,
}: {
entry: WizardEntry;
onToggle: () => void;
onChange: (p: AgentProfile) => void;
onRemove: () => void;
}) {
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
return (
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={selected}
onChange={onToggle}
aria-label={`use ${profile.name}`}
className="accent-primary"
/>
<strong className="text-sm text-content">{profile.name}</strong>
</label>
<span
aria-label={`${profile.name} availability`}
className={cn(
"text-xs",
available === null
? "text-faint"
: available
? "text-success"
: "text-danger",
)}
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
</span>
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
onClick={onRemove}
className="ml-auto"
>
×
</IconButton>
</div>
<label className="flex flex-col gap-1">
<Caption>Command</Caption>
<Input
aria-label={`${profile.name} command`}
value={profile.command}
invalid={Boolean(errors.command)}
onChange={(e) => onChange({ ...profile, command: e.target.value })}
/>
{errors.command && <small className="text-xs text-danger">{errors.command}</small>}
</label>
<label className="flex flex-col gap-1">
<Caption>Arguments</Caption>
<Input
aria-label={`${profile.name} args`}
value={profile.args.join(" ")}
onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })}
/>
</label>
</li>
);
}
/** Inline form to add a custom profile, validated before it is accepted. */
function AddCustomProfile({ onAdd }: { onAdd: (p: AgentProfile) => void }) {
const [draft, setDraft] = useState<AgentProfile>(emptyCustomProfile());
const errors = validateProfile(draft);
const valid = isProfileValid(draft);
const ci = draft.contextInjection;
function submit(e: React.FormEvent) {
e.preventDefault();
if (!valid) return;
onAdd(draft);
setDraft(emptyCustomProfile());
}
return (
<form
onSubmit={submit}
aria-label="add custom profile"
className="flex flex-col gap-2 rounded-md border border-dashed border-border-strong p-3"
>
<strong className="text-sm text-content">Add a custom profile</strong>
<Input
aria-label="custom name"
placeholder="Name"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
<Input
aria-label="custom command"
placeholder="Command (e.g. my-ai)"
value={draft.command}
onChange={(e) => setDraft({ ...draft, command: e.target.value })}
/>
<Input
aria-label="custom args"
placeholder="Arguments (space-separated)"
value={draft.args.join(" ")}
onChange={(e) => setDraft({ ...draft, args: parseArgs(e.target.value) })}
/>
<label className="flex flex-col gap-1">
<Caption>Context injection</Caption>
<select
aria-label="injection strategy"
className={SELECT_CLASS}
value={ci.strategy}
onChange={(e) =>
setDraft({
...draft,
contextInjection: defaultInjection(
e.target.value as InjectionStrategy,
),
})
}
>
<option value="conventionFile">Convention file</option>
<option value="flag">Flag</option>
<option value="stdin">Stdin</option>
<option value="env">Environment variable</option>
</select>
</label>
{ci.strategy === "conventionFile" && (
<Input
aria-label="injection target"
placeholder="Target file (e.g. CONTEXT.md)"
value={ci.target}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "conventionFile", target: e.target.value },
})
}
/>
)}
{ci.strategy === "flag" && (
<Input
aria-label="injection flag"
placeholder="Flag (e.g. --context-file {path})"
value={ci.flag}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "flag", flag: e.target.value },
})
}
/>
)}
{ci.strategy === "env" && (
<Input
aria-label="injection var"
placeholder="Env var (e.g. AGENT_CONTEXT_FILE)"
value={ci.var}
onChange={(e) =>
setDraft({
...draft,
contextInjection: { strategy: "env", var: e.target.value },
})
}
/>
)}
{Object.values(errors).map((msg) => (
<small key={msg} className="text-xs text-danger">
{msg}
</small>
))}
<Button type="submit" variant="primary" disabled={!valid}>
Add custom profile
</Button>
</form>
);
}

View File

@ -0,0 +1,102 @@
/**
* Minimal "Settings → AI Profiles" panel (L5). An always-available entry point
* to review the configured profiles and re-run the setup wizard after the first
* run. Kept intentionally small; richer per-profile editing reuses the wizard.
*
* Pure presentation over the {@link ProfileGateway} port (no `invoke()`).
*/
import { useCallback, useEffect, useState } from "react";
import type { AgentProfile, GatewayError } from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Panel } from "@/shared";
import { FirstRunWizard } from "./FirstRunWizard";
export function ProfilesSettings() {
const { profile } = useGateways();
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const refresh = useCallback(async () => {
setError(null);
try {
setProfiles(await profile.listProfiles());
} catch (e) {
setError(
e && typeof e === "object" && "message" in e
? String((e as GatewayError).message)
: String(e),
);
}
}, [profile]);
useEffect(() => {
void refresh();
}, [refresh]);
async function del(id: string) {
await profile.deleteProfile(id);
await refresh();
}
if (editing) {
// Reopened after the first run, so force the wizard to render.
return (
<FirstRunWizard
forceOpen
onDone={() => {
setEditing(false);
void refresh();
}}
/>
);
}
return (
<Panel
aria-label="ai profiles settings"
title="AI Profiles"
actions={
<Button size="sm" onClick={() => setEditing(true)}>
Configure profiles
</Button>
}
>
<div className="flex flex-col gap-3">
{error && (
<p role="alert" className="text-sm text-danger">
{error}
</p>
)}
{profiles.length === 0 ? (
<p className="text-sm text-muted">No profiles configured.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{profiles.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
>
<span className="flex items-baseline gap-2">
<strong className="text-sm text-content">{p.name}</strong>
<code className="text-xs text-muted">{p.command}</code>
</span>
<Button
size="sm"
variant="ghost"
aria-label={`delete ${p.name}`}
onClick={() => void del(p.id)}
>
Delete
</Button>
</li>
))}
</ul>
)}
</div>
</Panel>
);
}

View File

@ -0,0 +1,19 @@
/**
* First-run & AI-profiles feature (L5). Public surface: the wizard, the settings
* panel, the view-model hook, and the pure profile helpers.
*/
export { FirstRunWizard } from "./FirstRunWizard";
export { ProfilesSettings } from "./ProfilesSettings";
export { useFirstRun, type FirstRunViewModel, type WizardEntry } from "./useFirstRun";
export {
validateProfile,
isProfileValid,
isRelativeSafe,
isValidEnvVar,
defaultInjection,
emptyCustomProfile,
newProfileId,
parseArgs,
type ProfileErrors,
} from "./profile";

View File

@ -0,0 +1,131 @@
/**
* L5 — pure first-run profile logic: validation mirrors of the backend invariants
* (`validateProfile`/`isProfileValid`), `isRelativeSafe`, `isValidEnvVar`,
* `defaultInjection`, `emptyCustomProfile`, and `parseArgs`.
*/
import { describe, it, expect } from "vitest";
import type { AgentProfile } from "@/domain";
import {
defaultInjection,
emptyCustomProfile,
isProfileValid,
isRelativeSafe,
isValidEnvVar,
parseArgs,
validateProfile,
} from "./profile";
function base(overrides: Partial<AgentProfile> = {}): AgentProfile {
return {
id: "p1",
name: "Claude",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: null,
cwdTemplate: "{projectRoot}",
...overrides,
};
}
describe("isRelativeSafe", () => {
it("accepts a plain relative file name", () => {
expect(isRelativeSafe("CLAUDE.md")).toBe(true);
expect(isRelativeSafe("docs/AGENTS.md")).toBe(true);
});
it("rejects empty, absolute, traversal and drive paths", () => {
expect(isRelativeSafe("")).toBe(false);
expect(isRelativeSafe("/etc/passwd")).toBe(false);
expect(isRelativeSafe("\\windows")).toBe(false);
expect(isRelativeSafe("../secret")).toBe(false);
expect(isRelativeSafe("a/../b")).toBe(false);
expect(isRelativeSafe("C:/tmp")).toBe(false);
});
});
describe("isValidEnvVar", () => {
it("accepts valid identifiers", () => {
expect(isValidEnvVar("AGENT_CONTEXT")).toBe(true);
expect(isValidEnvVar("_x1")).toBe(true);
});
it("rejects invalid identifiers", () => {
expect(isValidEnvVar("")).toBe(false);
expect(isValidEnvVar("1ABC")).toBe(false);
expect(isValidEnvVar("HAS-DASH")).toBe(false);
expect(isValidEnvVar("has space")).toBe(false);
});
});
describe("validateProfile / isProfileValid", () => {
it("a well-formed profile is valid", () => {
expect(validateProfile(base())).toEqual({});
expect(isProfileValid(base())).toBe(true);
});
it("blank name and command are reported", () => {
const errors = validateProfile(base({ name: " ", command: "" }));
expect(errors.name).toBeDefined();
expect(errors.command).toBeDefined();
expect(isProfileValid(base({ name: "" }))).toBe(false);
});
it("convention file target must be relative-safe", () => {
const errors = validateProfile(
base({ contextInjection: { strategy: "conventionFile", target: "../x" } }),
);
expect(errors.target).toBeDefined();
});
it("flag must be non-empty", () => {
const errors = validateProfile(
base({ contextInjection: { strategy: "flag", flag: " " } }),
);
expect(errors.flag).toBeDefined();
});
it("env var must be a valid identifier", () => {
const errors = validateProfile(
base({ contextInjection: { strategy: "env", var: "1bad" } }),
);
expect(errors.var).toBeDefined();
});
it("stdin needs no extra field", () => {
expect(
isProfileValid(base({ contextInjection: { strategy: "stdin" } })),
).toBe(true);
});
});
describe("defaultInjection", () => {
it("produces a sensible default per strategy", () => {
expect(defaultInjection("conventionFile")).toEqual({
strategy: "conventionFile",
target: "CONTEXT.md",
});
expect(defaultInjection("flag")).toEqual({
strategy: "flag",
flag: "--context-file {path}",
});
expect(defaultInjection("env")).toEqual({
strategy: "env",
var: "AGENT_CONTEXT_FILE",
});
expect(defaultInjection("stdin")).toEqual({ strategy: "stdin" });
});
});
describe("emptyCustomProfile", () => {
it("is a blank, invalid draft with a fresh id", () => {
const a = emptyCustomProfile();
const b = emptyCustomProfile();
expect(a.name).toBe("");
expect(a.command).toBe("");
expect(isProfileValid(a)).toBe(false);
expect(a.id).not.toEqual(b.id);
});
});
describe("parseArgs", () => {
it("splits on whitespace, trims and drops empties", () => {
expect(parseArgs(" --foo bar\t baz ")).toEqual(["--foo", "bar", "baz"]);
expect(parseArgs("")).toEqual([]);
expect(parseArgs(" ")).toEqual([]);
});
});

View File

@ -0,0 +1,103 @@
/**
* Pure, framework-free helpers for the first-run wizard (testable without React
* or Tauri, ARCHITECTURE §1.3). Validation of a custom/edited profile and small
* factories live here; the components only render.
*/
import type {
AgentProfile,
ContextInjection,
InjectionStrategy,
} from "@/domain";
/** A field-keyed validation error map (empty ⇒ valid). */
export type ProfileErrors = Partial<
Record<"name" | "command" | "target" | "flag" | "var", string>
>;
/** Whether a path is a relative, traversal-free file name (mirror of backend). */
export function isRelativeSafe(path: string): boolean {
if (path.length === 0) return false;
if (path.startsWith("/") || path.startsWith("\\")) return false;
// Windows drive (C:) / UNC.
if (/^[a-zA-Z]:/.test(path)) return false;
return !path.split(/[/\\]/).includes("..");
}
/** Whether a string is a valid environment-variable identifier. */
export function isValidEnvVar(v: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v);
}
/**
* Validates a profile draft the way the backend would, so the wizard can surface
* errors before any `invoke`. Returns an empty object when the draft is valid.
*/
export function validateProfile(p: AgentProfile): ProfileErrors {
const errors: ProfileErrors = {};
if (p.name.trim().length === 0) errors.name = "Name is required.";
if (p.command.trim().length === 0) errors.command = "Command is required.";
const ci = p.contextInjection;
if (ci.strategy === "conventionFile") {
if (!isRelativeSafe(ci.target)) {
errors.target = "Target must be a relative file name (no .. or absolute).";
}
} else if (ci.strategy === "flag") {
if (ci.flag.trim().length === 0) errors.flag = "Flag is required.";
} else if (ci.strategy === "env") {
if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier.";
}
return errors;
}
/** Whether a profile draft is valid (no errors). */
export function isProfileValid(p: AgentProfile): boolean {
return Object.keys(validateProfile(p)).length === 0;
}
/**
* Builds a default {@link ContextInjection} for a strategy, so switching the
* strategy dropdown produces a sensible editable shape.
*/
export function defaultInjection(strategy: InjectionStrategy): ContextInjection {
switch (strategy) {
case "conventionFile":
return { strategy, target: "CONTEXT.md" };
case "flag":
return { strategy, flag: "--context-file {path}" };
case "env":
return { strategy, var: "AGENT_CONTEXT_FILE" };
case "stdin":
return { strategy };
}
}
/** A fresh, empty custom-profile draft (id minted client-side). */
export function emptyCustomProfile(): AgentProfile {
return {
id: newProfileId(),
name: "",
command: "",
args: [],
contextInjection: { strategy: "conventionFile", target: "CONTEXT.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
}
/** Generates a UUID for a client-created profile (crypto when available). */
export function newProfileId(): string {
const c = globalThis.crypto as Crypto | undefined;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
// Fallback for non-secure contexts/tests.
return `profile-${Math.random().toString(36).slice(2, 10)}`;
}
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
export function parseArgs(raw: string): string[] {
return raw
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}

View File

@ -0,0 +1,178 @@
/**
* `useFirstRun` — view-model for the first-run wizard and profile management
* (L5). Owns the wizard state (reference candidates + selection + availability +
* custom profiles) and the actions. Consumes the {@link ProfileGateway}
* exclusively — never `invoke()` — so the feature is testable with a mock.
*/
import { useCallback, useEffect, useState } from "react";
import type {
AgentProfile,
FirstRunState,
GatewayError,
ProfileAvailability,
} from "@/domain";
import { useGateways } from "@/app/di";
/** One row in the wizard: a candidate profile, selected state, availability. */
export interface WizardEntry {
profile: AgentProfile;
selected: boolean;
/** `null` until detection ran; then `true`/`false`. */
available: boolean | null;
}
/** What the first-run wizard UI needs from this hook. */
export interface FirstRunViewModel {
/** Whether the wizard should be shown (null while loading). */
isFirstRun: boolean | null;
/** The candidate rows (reference + added custom profiles). */
entries: WizardEntry[];
/** Last error message, or null. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Toggles selection of a candidate by id. */
toggle: (id: string) => void;
/** Replaces a candidate's profile (edited command/args/injection). */
updateProfile: (id: string, profile: AgentProfile) => void;
/** Adds a custom profile (selected by default). */
addCustom: (profile: AgentProfile) => void;
/** Removes a candidate by id (e.g. a custom one). */
remove: (id: string) => void;
/** Runs detection over all candidates, filling availability. */
detect: () => Promise<void>;
/** Persists the selected profiles and closes the wizard. */
finish: () => Promise<void>;
/** Re-checks first-run state (e.g. reopening profile management). */
reload: () => 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 useFirstRun(): FirstRunViewModel {
const { profile } = useGateways();
const [isFirstRun, setIsFirstRun] = useState<boolean | null>(null);
const [entries, setEntries] = useState<WizardEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
setBusy(true);
setError(null);
try {
const state: FirstRunState = await profile.firstRunState();
setIsFirstRun(state.isFirstRun);
const refs = state.referenceProfiles;
// Show the rows immediately (nothing pre-checked yet).
setEntries(
refs.map((p) => ({ profile: p, selected: false, available: null })),
);
// Auto-detect once on open: only the CLIs actually installed end up
// pre-checked. The manual "Detect installed CLIs" button stays available to
// re-probe. If detection is unavailable, rows stay unchecked (the user can
// still tick them by hand and re-run detection).
try {
const results = await profile.detectProfiles(refs);
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
setEntries(
refs.map((p) => {
const available = byId.get(p.id) ?? null;
return { profile: p, selected: available === true, available };
}),
);
} catch {
/* detection unavailable: leave rows unchecked */
}
} catch (e) {
setError(describe(e));
setIsFirstRun(false);
} finally {
setBusy(false);
}
}, [profile]);
useEffect(() => {
void reload();
}, [reload]);
const toggle = useCallback((id: string) => {
setEntries((prev) =>
prev.map((e) =>
e.profile.id === id ? { ...e, selected: !e.selected } : e,
),
);
}, []);
const updateProfile = useCallback((id: string, next: AgentProfile) => {
setEntries((prev) =>
prev.map((e) => (e.profile.id === id ? { ...e, profile: next } : e)),
);
}, []);
const addCustom = useCallback((p: AgentProfile) => {
setEntries((prev) => [
...prev,
{ profile: p, selected: true, available: null },
]);
}, []);
const remove = useCallback((id: string) => {
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
}, []);
const detect = useCallback(async () => {
setBusy(true);
setError(null);
try {
const candidates = entries.map((e) => e.profile);
const results: ProfileAvailability[] =
await profile.detectProfiles(candidates);
const byId = new Map(results.map((r) => [r.profile.id, r.available]));
setEntries((prev) =>
prev.map((e) => ({
...e,
available: byId.get(e.profile.id) ?? e.available,
})),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [profile, entries]);
const finish = useCallback(async () => {
setBusy(true);
setError(null);
try {
const chosen = entries.filter((e) => e.selected).map((e) => e.profile);
await profile.configureProfiles(chosen);
setIsFirstRun(false);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [profile, entries]);
return {
isFirstRun,
entries,
error,
busy,
toggle,
updateProfile,
addCustom,
remove,
detect,
finish,
reload,
};
}

View File

@ -0,0 +1,354 @@
/**
* `GitGraphView` — full git-graph layout (L8 / git-graph view).
*
* Renders the commit DAG for a project: each row shows an SVG node+edges on
* the left, ref badges (branches in primary blue, tags in amber), a short
* hash, the commit summary, author, and relative date. Clicking a row opens a
* detail panel on the right.
*
* Data flow: `useGitGraph` → `computeGraphRows` → render rows.
* Never calls `invoke()` — all data comes through the GitGateway port.
*/
import { useCallback, useEffect, useState } from "react";
import type { GraphCommit } from "@/domain";
import { useGateways } from "@/app/di";
import { Spinner, cn } from "@/shared";
import { computeGraphRows, type GraphLink, type GraphRow } from "./graphLayout";
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface GitGraphViewModel {
commits: GraphCommit[];
rows: GraphRow[];
busy: boolean;
error: string | null;
refresh: () => Promise<void>;
}
const GRAPH_LIMIT = 200;
function useGitGraph(projectId: string): GitGraphViewModel {
const { git } = useGateways();
const [commits, setCommits] = useState<GraphCommit[]>([]);
const [rows, setRows] = useState<GraphRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const data = await git.graph(projectId, GRAPH_LIMIT);
setCommits(data);
setRows(computeGraphRows(data));
} catch (e) {
setError(e && typeof e === "object" && "message" in e ? String((e as { message: unknown }).message) : String(e));
} finally {
setBusy(false);
}
}, [git, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
return { commits, rows, busy, error, refresh };
}
// ---------------------------------------------------------------------------
// SVG graph column
// ---------------------------------------------------------------------------
/** Width per lane in the SVG graph column. */
const LANE_W = 16;
/** Row height must match the rendered row height so lanes connect across rows. */
const ROW_H = 36;
/** Radius of the commit dot. */
const DOT_R = 4.5;
/** Horizontal centre of a lane. */
const laneX = (lane: number) => lane * LANE_W + LANE_W / 2;
/** SVG path for a link spanning `y0`→`y1` between two lanes (curved when bent). */
function linkPath(link: GraphLink, y0: number, y1: number): string {
const x0 = laneX(link.fromLane);
const x1 = laneX(link.toLane);
if (x0 === x1) return `M ${x0} ${y0} L ${x1} ${y1}`; // straight lane line
const ym = (y0 + y1) / 2;
return `M ${x0} ${y0} C ${x0} ${ym} ${x1} ${ym} ${x1} ${y1}`; // smooth S-bend
}
interface GraphColumnProps {
row: GraphRow;
/** Total lane columns across the whole graph (fixed width so lanes align). */
laneCount: number;
}
/** Renders the SVG lanes (incoming + outgoing) and the commit node for one row. */
function GraphColumn({ row, laneCount }: GraphColumnProps) {
const w = laneCount * LANE_W + LANE_W / 2;
const mid = ROW_H / 2;
const cx = laneX(row.lane);
return (
<svg width={w} height={ROW_H} aria-hidden className="shrink-0 overflow-visible">
{/* Top half: top boundary → node mid */}
{row.incoming.map((link, i) => (
<path
key={`in-${i}`}
d={linkPath(link, 0, mid)}
fill="none"
stroke={link.color}
strokeWidth={2}
/>
))}
{/* Bottom half: node mid → bottom boundary */}
{row.outgoing.map((link, i) => (
<path
key={`out-${i}`}
d={linkPath(link, mid, ROW_H)}
fill="none"
stroke={link.color}
strokeWidth={2}
/>
))}
{/* Commit node: filled dot with a dark ring so it reads over the lanes */}
<circle cx={cx} cy={mid} r={DOT_R + 1.5} className="fill-canvas" />
<circle
cx={cx}
cy={mid}
r={DOT_R}
fill={row.color}
stroke="var(--color-canvas)"
strokeWidth={1}
/>
</svg>
);
}
// ---------------------------------------------------------------------------
// Relative date helper
// ---------------------------------------------------------------------------
function relativeDate(timestamp: number): string {
const secs = Math.floor(Date.now() / 1000) - timestamp;
if (secs < 60) return "just now";
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
if (secs < 2592000) return `${Math.floor(secs / 86400)}d ago`;
if (secs < 31536000) return `${Math.floor(secs / 2592000)}mo ago`;
return `${Math.floor(secs / 31536000)}y ago`;
}
// ---------------------------------------------------------------------------
// Detail panel
// ---------------------------------------------------------------------------
interface CommitDetailProps {
commit: GraphCommit;
onClose: () => void;
}
function CommitDetail({ commit, onClose }: CommitDetailProps) {
const isTag = (ref: string) => ref.startsWith("tag:");
return (
<aside
aria-label="commit detail"
className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto border-l border-border bg-surface p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-content">{commit.summary}</h3>
<button
type="button"
aria-label="close commit detail"
onClick={onClose}
className="shrink-0 text-muted hover:text-content"
>
×
</button>
</div>
<dl className="flex flex-col gap-2 text-xs">
<div>
<dt className="font-medium text-faint">Hash</dt>
<dd>
<code className="text-content break-all">{commit.hash}</code>
</dd>
</div>
<div>
<dt className="font-medium text-faint">Author</dt>
<dd className="text-content">{commit.author}</dd>
</div>
<div>
<dt className="font-medium text-faint">Date</dt>
<dd className="text-content">
{new Date(commit.timestamp * 1000).toLocaleString()}
</dd>
</div>
{commit.parents.length > 0 && (
<div>
<dt className="font-medium text-faint">Parents</dt>
<dd className="flex flex-col gap-0.5">
{commit.parents.map((p) => (
<code key={p} className="text-content">
{p.slice(0, 8)}
</code>
))}
</dd>
</div>
)}
{commit.refs.length > 0 && (
<div>
<dt className="font-medium text-faint">Refs</dt>
<dd className="flex flex-wrap gap-1">
{commit.refs.map((ref) => (
<span
key={ref}
className={cn(
"rounded px-1 py-0.5 text-xs font-medium text-base",
isTag(ref) ? "bg-warning/20 text-warning" : "bg-primary/20 text-primary",
)}
>
{ref}
</span>
))}
</dd>
</div>
)}
</dl>
</aside>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export interface GitGraphViewProps {
projectId: string;
}
export function GitGraphView({ projectId }: GitGraphViewProps) {
const vm = useGitGraph(projectId);
const [selectedHash, setSelectedHash] = useState<string | null>(null);
const selectedCommit =
selectedHash != null
? vm.commits.find((c) => c.hash === selectedHash) ?? null
: null;
// Fixed lane-column count across all rows so the lanes line up vertically.
const laneCount = vm.rows.reduce((m, r) => Math.max(m, r.laneCount), 1);
const isTag = (ref: string) => ref.startsWith("tag:");
return (
<div className="flex flex-1 overflow-hidden" aria-label="git graph">
{/* ── Graph + commit list ── */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-surface px-3 py-2">
<span className="text-sm font-semibold text-content">Git Graph</span>
{vm.busy && <Spinner size={14} />}
<button
type="button"
aria-label="refresh git graph"
onClick={() => void vm.refresh()}
disabled={vm.busy}
className="ml-auto text-xs text-muted hover:text-content disabled:opacity-40"
>
Refresh
</button>
</div>
{/* Error */}
{vm.error && (
<p
role="alert"
className="mx-3 mt-2 shrink-0 rounded border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"
>
{vm.error}
</p>
)}
{/* Rows */}
<div className="flex-1 overflow-y-auto" role="list" aria-label="commits">
{vm.rows.length === 0 && !vm.busy && (
<p className="p-4 text-sm text-muted">No commits found.</p>
)}
{vm.rows.map((row) => {
const isSelected = row.commit.hash === selectedHash;
return (
<div
key={row.commit.hash}
role="listitem"
aria-label={`commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`}
onClick={() =>
setSelectedHash(isSelected ? null : row.commit.hash)
}
className={cn(
"flex cursor-pointer items-center gap-2 border-b border-border/50 px-2 hover:bg-raised",
isSelected && "bg-raised",
)}
style={{ height: ROW_H }}
>
{/* SVG lanes + node */}
<GraphColumn row={row} laneCount={laneCount} />
{/* Ref badges */}
{row.commit.refs.length > 0 && (
<div className="flex shrink-0 flex-wrap gap-1">
{row.commit.refs.map((ref) => (
<span
key={ref}
className={cn(
"rounded px-1 py-0.5 text-xs font-medium",
isTag(ref)
? "bg-warning/20 text-warning"
: "bg-primary/20 text-primary",
)}
>
{ref}
</span>
))}
</div>
)}
{/* Short hash */}
<code className="shrink-0 text-xs text-faint">
{row.commit.hash.slice(0, 8)}
</code>
{/* Summary */}
<span className="min-w-0 flex-1 truncate text-sm text-content">
{row.commit.summary}
</span>
{/* Author + relative date */}
<span className="shrink-0 text-xs text-muted">
{row.commit.author}
</span>
<span className="shrink-0 text-xs text-faint">
{relativeDate(row.commit.timestamp)}
</span>
</div>
);
})}
</div>
</div>
{/* ── Detail panel (shown when a commit is selected) ── */}
{selectedCommit && (
<CommitDetail
commit={selectedCommit}
onClose={() => setSelectedHash(null)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,295 @@
/**
* `GitPanel` — feature component for the Git panel (L8).
*
* Shows the working-tree status grouped by Staged / Unstaged, a commit form,
* branch management with checkout, and the recent commit log.
*
* Pure presentation: all behaviour comes from {@link useGit}. Styled with
* `@/shared`; no inline styles.
*/
import { useState } from "react";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useGit } from "./useGit";
import { buildFileTree, type GitTreeNode } from "./gitTree";
/**
* Recursively renders a changed-files tree. Directories are static rows; file
* leaves carry the stage/unstage action (its `aria-label` uses the full path).
*/
function FileTree({
nodes,
depth,
action,
onAction,
busy,
}: {
nodes: GitTreeNode[];
depth: number;
action: "stage" | "unstage";
onAction: (path: string) => void;
busy: boolean;
}) {
return (
<ul className="flex flex-col gap-0.5">
{nodes.map((node) =>
node.isDir ? (
<li key={node.path}>
<div
className="flex items-center gap-1 px-2 py-0.5 text-xs text-muted"
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
<span aria-hidden className="text-faint">
</span>
<span className="truncate">{node.name}</span>
</div>
<FileTree
nodes={node.children}
depth={depth + 1}
action={action}
onAction={onAction}
busy={busy}
/>
</li>
) : (
<li
key={node.path}
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-raised"
style={{ paddingLeft: `${depth * 14 + 8}px` }}
>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden
className={cn(
"size-1.5 shrink-0 rounded-full",
action === "unstage" ? "bg-success" : "bg-warning",
)}
/>
<code className="min-w-0 truncate text-xs text-content">
{node.name}
</code>
</span>
<Button
size="sm"
variant="ghost"
aria-label={`${action} ${node.path}`}
disabled={busy}
onClick={() => onAction(node.path)}
className="shrink-0 text-muted hover:text-content"
>
{action === "unstage" ? "Unstage" : "Stage"}
</Button>
</li>
),
)}
</ul>
);
}
export interface GitPanelProps {
/** The project whose git state to manage. */
projectId: string;
}
export function GitPanel({ projectId }: GitPanelProps) {
const vm = useGit(projectId);
const [commitMessage, setCommitMessage] = useState("");
const [newBranch, setNewBranch] = useState("");
const staged = vm.files.filter((f) => f.staged);
const unstaged = vm.files.filter((f) => !f.staged);
const canCommit = commitMessage.trim().length > 0 && staged.length > 0 && !vm.busy;
async function handleCommit(e: React.FormEvent) {
e.preventDefault();
if (!canCommit) return;
await vm.commit(commitMessage.trim());
setCommitMessage("");
}
async function handleCheckout(branch: string) {
await vm.checkout(branch);
}
async function handleNewBranch(e: React.FormEvent) {
e.preventDefault();
const b = newBranch.trim();
if (!b || vm.busy) return;
await vm.checkout(b);
setNewBranch("");
}
return (
<Panel title="Git" className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Changes ── */}
<section aria-label="Changes" className="border-b border-border p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Changes
</h4>
{vm.busy && <Spinner size={12} />}
</div>
{vm.files.length === 0 ? (
<p className="text-sm text-muted">No changes.</p>
) : (
<div className="flex flex-col gap-3">
{/* Staged — shown as a file tree. */}
{staged.length > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-muted">Staged</p>
<FileTree
nodes={buildFileTree(staged)}
depth={0}
action="unstage"
onAction={(p) => void vm.unstage(p)}
busy={vm.busy}
/>
</div>
)}
{/* Unstaged — shown as a file tree. */}
{unstaged.length > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-muted">Unstaged</p>
<FileTree
nodes={buildFileTree(unstaged)}
depth={0}
action="stage"
onAction={(p) => void vm.stage(p)}
busy={vm.busy}
/>
</div>
)}
</div>
)}
</section>
{/* ── Commit ── */}
<section aria-label="Commit" className="border-b border-border p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Commit
</h4>
<form onSubmit={handleCommit} className="flex flex-col gap-2">
<Input
aria-label="commit message"
placeholder="Commit message"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
<Button
type="submit"
variant="primary"
disabled={!canCommit}
loading={vm.busy}
className="self-end"
>
Commit
</Button>
</form>
</section>
{/* ── Branches ── */}
<section aria-label="Branches" className="border-b border-border p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Branches
</h4>
<p className="mb-2 text-sm text-content">
Current:{" "}
<span className="font-medium">
{vm.branches.current ?? "(none)"}
</span>
</p>
{vm.branches.branches.length > 0 && (
<ul className="mb-3 flex flex-col gap-0.5">
{vm.branches.branches.map((b) => {
const isCurrent = b === vm.branches.current;
return (
<li
key={b}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1",
isCurrent && "bg-raised",
)}
>
<span
className={cn(
"text-sm",
isCurrent ? "font-semibold text-primary" : "text-content",
)}
>
{b}
</span>
{!isCurrent && (
<Button
size="sm"
variant="ghost"
aria-label={`checkout ${b}`}
disabled={vm.busy}
onClick={() => void handleCheckout(b)}
className="shrink-0 text-muted hover:text-content"
>
Checkout
</Button>
)}
</li>
);
})}
</ul>
)}
<form onSubmit={handleNewBranch} className="flex items-center gap-2">
<Input
aria-label="new branch name"
placeholder="New branch"
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
className="flex-1"
/>
<Button
type="submit"
variant="ghost"
disabled={!newBranch.trim() || vm.busy}
>
Create &amp; checkout
</Button>
</form>
</section>
{/* ── Log ── */}
<section aria-label="Log" className="p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Log
</h4>
{vm.log.length === 0 ? (
<p className="text-sm text-muted">No commits yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.log.map((c) => (
<li key={c.hash} className="flex items-baseline gap-2 py-1.5">
<code className="shrink-0 text-xs text-faint">
{c.hash.slice(0, 7)}
</code>
<span className="min-w-0 truncate text-sm text-content">
{c.summary}
</span>
</li>
))}
</ul>
)}
</section>
</Panel>
);
}

View File

@ -0,0 +1,284 @@
/**
* L8 — git feature wired to the stateful `MockGitGateway` via the real
* `DIProvider`.
*
* Covers:
* - status shows staged/unstaged groupings
* - stage moves a file from Unstaged to Staged
* - commit with empty message is blocked (button disabled)
* - commit with message but nothing staged is blocked (button disabled)
* - commit moves staged files out and adds an entry to the log
* - checkout changes the current branch
* - MockGitGateway unit behaviour
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockGitGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { GitPanel } from "./GitPanel";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const PROJECT_ID = "proj-git-test-001";
function renderPanel(git: MockGitGateway = new MockGitGateway()) {
const gateways = { git } as unknown as Gateways;
return {
git,
...render(
<DIProvider gateways={gateways}>
<GitPanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
/** Waits until the "Changes" section is rendered (panel stabilised). */
async function waitForPanel() {
await waitFor(() => {
expect(screen.getByRole("region", { name: "Changes" })).toBeTruthy();
});
}
// ---------------------------------------------------------------------------
// Integration tests (GitPanel + MockGitGateway via DIProvider)
// ---------------------------------------------------------------------------
describe("GitPanel (with MockGitGateway)", () => {
it("shows demo files split between Staged and Unstaged sections", async () => {
const git = new MockGitGateway();
// Seed a staged file.
await git.init(PROJECT_ID);
await git.stage(PROJECT_ID, "src/main.rs");
renderPanel(git);
await waitForPanel();
expect(screen.getByText("Staged")).toBeTruthy();
expect(screen.getByText("Unstaged")).toBeTruthy();
// src/main.rs is staged → Unstage button exists
expect(
screen.getByRole("button", { name: "unstage src/main.rs" }),
).toBeTruthy();
// README.md is unstaged → Stage button exists
expect(
screen.getByRole("button", { name: "stage README.md" }),
).toBeTruthy();
});
it("clicking Stage moves a file to the Staged group", async () => {
renderPanel();
await waitForPanel();
// Stage README.md
const stageBtn = await screen.findByRole("button", {
name: "stage README.md",
});
fireEvent.click(stageBtn);
// After staging, an Unstage button should appear for README.md
await waitFor(() => {
expect(
screen.getByRole("button", { name: "unstage README.md" }),
).toBeTruthy();
});
});
it("clicking Unstage moves a file back to Unstaged", async () => {
const git = new MockGitGateway();
await git.init(PROJECT_ID);
await git.stage(PROJECT_ID, "src/main.rs");
renderPanel(git);
await waitForPanel();
const unstageBtn = await screen.findByRole("button", {
name: "unstage src/main.rs",
});
fireEvent.click(unstageBtn);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "stage src/main.rs" }),
).toBeTruthy();
});
});
it("Commit button is disabled when message is empty", async () => {
renderPanel();
await waitForPanel();
const btn = screen.getByRole("button", { name: /commit/i });
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("Commit button is disabled when message is set but nothing is staged", async () => {
renderPanel();
await waitForPanel();
fireEvent.change(screen.getByLabelText("commit message"), {
target: { value: "my message" },
});
const btn = screen.getByRole("button", { name: /commit/i });
expect((btn as HTMLButtonElement).disabled).toBe(true);
});
it("successful commit removes staged files and adds to the log", async () => {
const git = new MockGitGateway();
await git.init(PROJECT_ID);
await git.stage(PROJECT_ID, "src/main.rs");
await git.stage(PROJECT_ID, "README.md");
renderPanel(git);
await waitForPanel();
fireEvent.change(screen.getByLabelText("commit message"), {
target: { value: "initial commit" },
});
const btn = screen.getByRole("button", { name: /commit/i });
expect((btn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(btn);
// After commit, no files should remain (both were staged).
await waitFor(() => {
expect(screen.getByText("No changes.")).toBeTruthy();
});
// The commit should appear in the log.
await waitFor(() => {
expect(screen.getByText("initial commit")).toBeTruthy();
});
});
it("checkout changes the current branch label", async () => {
renderPanel();
await waitForPanel();
// Ensure the Branches section is rendered with "main" as current.
const branchesSection = await screen.findByRole("region", {
name: "Branches",
});
expect(branchesSection).toBeTruthy();
// Create & checkout a new branch via the form.
const branchInput = screen.getByLabelText("new branch name");
fireEvent.change(branchInput, { target: { value: "feature/new-thing" } });
fireEvent.click(screen.getByRole("button", { name: /create.*checkout/i }));
// After checkout, the new branch should appear (may show in current + list).
await waitFor(() => {
const matches = screen.getAllByText("feature/new-thing");
expect(matches.length).toBeGreaterThan(0);
});
// A checkout button for "main" should now appear (since it's no longer current).
await waitFor(() => {
expect(
screen.getByRole("button", { name: "checkout main" }),
).toBeTruthy();
});
});
it("shows 'No commits yet.' in the Log section initially", async () => {
renderPanel();
await waitForPanel();
expect(screen.getByText("No commits yet.")).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// MockGitGateway unit tests
// ---------------------------------------------------------------------------
describe("MockGitGateway (unit)", () => {
it("status returns seeded files for a new project", async () => {
const gw = new MockGitGateway();
const files = await gw.status("proj");
expect(files.length).toBeGreaterThan(0);
expect(files.every((f) => !f.staged)).toBe(true);
});
it("init creates state if absent", async () => {
const gw = new MockGitGateway();
await gw.init("new-proj");
const files = await gw.status("new-proj");
expect(Array.isArray(files)).toBe(true);
});
it("stage toggles a file to staged=true", async () => {
const gw = new MockGitGateway();
const before = await gw.status("p");
const target = before[0].path;
await gw.stage("p", target);
const after = await gw.status("p");
const file = after.find((f) => f.path === target)!;
expect(file.staged).toBe(true);
});
it("unstage toggles a file back to staged=false", async () => {
const gw = new MockGitGateway();
const files = await gw.status("p");
const target = files[0].path;
await gw.stage("p", target);
await gw.unstage("p", target);
const after = await gw.status("p");
expect(after.find((f) => f.path === target)!.staged).toBe(false);
});
it("commit removes staged files from status and adds to log", async () => {
const gw = new MockGitGateway();
const files = await gw.status("p");
const target = files[0].path;
await gw.stage("p", target);
const result = await gw.commit("p", "test commit\n\nmore text");
expect(result.summary).toBe("test commit");
expect(result.hash).toMatch(/^mock-/);
const after = await gw.status("p");
expect(after.find((f) => f.path === target)).toBeUndefined();
const log = await gw.log("p", 10);
expect(log[0].summary).toBe("test commit");
});
it("branches returns initial ['main'] with current='main'", async () => {
const gw = new MockGitGateway();
const b = await gw.branches("p");
expect(b.branches).toEqual(["main"]);
expect(b.current).toBe("main");
});
it("checkout creates a new branch and sets it as current", async () => {
const gw = new MockGitGateway();
await gw.checkout("p", "develop");
const b = await gw.branches("p");
expect(b.branches).toContain("develop");
expect(b.current).toBe("develop");
});
it("log(limit) returns at most limit entries", async () => {
const gw = new MockGitGateway();
const files = await gw.status("p");
// Stage and commit each file individually.
for (let i = 0; i < files.length; i++) {
await gw.stage("p", files[i].path);
await gw.commit("p", `commit ${i}`);
}
const log = await gw.log("p", 1);
expect(log).toHaveLength(1);
});
it("projects are isolated", async () => {
const gw = new MockGitGateway();
await gw.stage("proj-A", (await gw.status("proj-A"))[0].path);
await gw.commit("proj-A", "A commit");
const logB = await gw.log("proj-B", 10);
expect(logB).toHaveLength(0);
});
});

View File

@ -0,0 +1,190 @@
/**
* Integration tests for `GitGraphView` via `MockGitGateway`.
*
* Covers:
* - The commits from the demo DAG are displayed.
* - Branch refs are shown with primary colour class.
* - Tag refs are shown with warning colour class.
* - Clicking a commit row opens the detail panel.
* - The detail panel shows full hash, author, and parents.
* - Clicking close hides the detail panel.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockGitGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { GitGraphView } from "./GitGraphView";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderView(git: MockGitGateway = new MockGitGateway()) {
const gateways = { git } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<GitGraphView projectId="proj-graph-test" />
</DIProvider>,
);
}
/** Waits until at least one commit row is rendered. */
async function waitForCommits() {
await waitFor(() => {
const list = screen.getByRole("list", { name: "commits" });
expect(list.children.length).toBeGreaterThan(0);
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("GitGraphView", () => {
it("renders commits from the mock graph", async () => {
renderView();
await waitForCommits();
// The demo DAG has "Merge feature into main" as the newest commit.
expect(screen.getByText("Merge feature into main")).toBeTruthy();
// And "Initial commit" as the oldest.
expect(screen.getByText("Initial commit")).toBeTruthy();
});
it("shows branch ref badge for 'main'", async () => {
renderView();
await waitForCommits();
// The "main" badge should appear (it's in the refs of the merge commit).
const mainBadges = screen.getAllByText("main");
expect(mainBadges.length).toBeGreaterThan(0);
});
it("shows tag ref badge for 'tag: v1.0'", async () => {
renderView();
await waitForCommits();
const tagBadge = screen.getByText("tag: v1.0");
expect(tagBadge).toBeTruthy();
// Tag badge should have the warning colour class
expect(tagBadge.className).toMatch(/warning/);
});
it("shows 'feature' branch badge", async () => {
renderView();
await waitForCommits();
const featureBadge = screen.getByText("feature");
expect(featureBadge).toBeTruthy();
expect(featureBadge.className).toMatch(/primary/);
});
it("clicking a commit row opens the detail panel", async () => {
renderView();
await waitForCommits();
// The detail panel should not be visible yet.
expect(screen.queryByRole("complementary", { name: "commit detail" })).toBeNull();
// Click the first commit row.
const items = screen.getAllByRole("listitem");
fireEvent.click(items[0]);
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
).toBeTruthy();
});
});
it("detail panel shows full commit hash and author", async () => {
renderView();
await waitForCommits();
// Click the merge commit row.
const mergeRow = screen.getByRole("listitem", {
name: /commit eeeeeeee/,
});
fireEvent.click(mergeRow);
await waitFor(() => {
// Full hash should appear in the detail panel (code element).
const fullHash = screen.getByText(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
);
expect(fullHash.tagName.toLowerCase()).toBe("code");
});
// Author should appear in a <dd> (detail panel's definition list).
await waitFor(() => {
const detail = screen.getByRole("complementary", { name: "commit detail" });
expect(detail.textContent).toContain("Alice");
});
});
it("detail panel lists parents for a merge commit", async () => {
renderView();
await waitForCommits();
const mergeRow = screen.getByRole("listitem", {
name: /commit eeeeeeee/,
});
fireEvent.click(mergeRow);
await waitFor(() => {
const detail = screen.getByRole("complementary", { name: "commit detail" });
// The merge commit has two parents displayed as short hashes.
expect(detail.textContent).toContain("bbbbbbbb");
expect(detail.textContent).toContain("dddddddd");
});
});
it("closing the detail panel hides it", async () => {
renderView();
await waitForCommits();
const items = screen.getAllByRole("listitem");
fireEvent.click(items[0]);
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
).toBeTruthy();
});
// Click the close button.
fireEvent.click(screen.getByRole("button", { name: "close commit detail" }));
await waitFor(() => {
expect(
screen.queryByRole("complementary", { name: "commit detail" }),
).toBeNull();
});
});
it("clicking the same row again deselects and hides detail", async () => {
renderView();
await waitForCommits();
const items = screen.getAllByRole("listitem");
fireEvent.click(items[0]);
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
).toBeTruthy();
});
// Click the same row again to deselect.
fireEvent.click(items[0]);
await waitFor(() => {
expect(
screen.queryByRole("complementary", { name: "commit detail" }),
).toBeNull();
});
});
});

View File

@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { buildFileTree } from "./gitTree";
describe("buildFileTree", () => {
it("folds paths into a nested directory tree", () => {
const tree = buildFileTree([
{ path: "src/app/main.rs", staged: false },
{ path: "src/lib.rs", staged: true },
{ path: "README.md", staged: false },
]);
// Directories first, then files, alphabetically: src/ then README.md.
expect(tree.map((n) => n.name)).toEqual(["src", "README.md"]);
const src = tree[0];
expect(src.isDir).toBe(true);
// Inside src: app/ (dir) then lib.rs (file).
expect(src.children.map((n) => n.name)).toEqual(["app", "lib.rs"]);
expect(src.children[0].isDir).toBe(true);
expect(src.children[0].children[0].path).toBe("src/app/main.rs");
expect(src.children[1].path).toBe("src/lib.rs");
// The README leaf keeps its full path + staged flag.
const readme = tree[1];
expect(readme.isDir).toBe(false);
expect(readme.path).toBe("README.md");
});
it("returns an empty array for no files", () => {
expect(buildFileTree([])).toEqual([]);
});
});

View File

@ -0,0 +1,69 @@
/**
* Pure helper that folds a flat list of changed file paths into a directory
* tree, so the Git panel can show changes as a file tree (L8 visual). Kept free
* of React for straightforward unit testing.
*/
import type { GitFileStatus } from "@/domain";
/** A node in the changed-files tree: a directory or a file leaf. */
export interface GitTreeNode {
/** Last path segment (folder or file name). */
name: string;
/** Full repo-relative path of this node. */
path: string;
/** Whether this node is a directory (has children) or a file leaf. */
isDir: boolean;
/** For file leaves: whether the change is staged. */
staged: boolean;
/** Child nodes (empty for files). */
children: GitTreeNode[];
}
/**
* Builds the directory tree for a set of changed files. Directories come before
* files at each level, both alphabetically sorted.
*/
export function buildFileTree(files: GitFileStatus[]): GitTreeNode[] {
const root: GitTreeNode = {
name: "",
path: "",
isDir: true,
staged: false,
children: [],
};
for (const file of files) {
const parts = file.path.split("/").filter(Boolean);
let node = root;
parts.forEach((part, i) => {
const isLeaf = i === parts.length - 1;
const path = parts.slice(0, i + 1).join("/");
let child = node.children.find(
(c) => c.name === part && c.isDir === !isLeaf,
);
if (!child) {
child = {
name: part,
path,
isDir: !isLeaf,
staged: file.staged,
children: [],
};
node.children.push(child);
}
node = child;
});
}
sort(root);
return root.children;
}
function sort(node: GitTreeNode): void {
node.children.sort((a, b) => {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
node.children.forEach(sort);
}

View File

@ -0,0 +1,109 @@
/**
* Unit tests for `computeGraphRows` — the pure lane-layout algorithm.
*
* Each row is split into `incoming` (top boundary → node) and `outgoing`
* (node → bottom boundary) links; a link with `fromLane !== toLane` is a bent
* fork/merge connector, otherwise it is a straight pass-through lane.
*/
import { describe, it, expect } from "vitest";
import { computeGraphRows } from "./graphLayout";
import type { GraphCommit } from "@/domain";
function commit(hash: string, parents: string[]): GraphCommit {
return { hash, summary: hash, parents, refs: [], author: "test", timestamp: 0 };
}
/** Just the (fromLane,toLane) pairs of a set of links. */
function pairs(links: { fromLane: number; toLane: number }[]) {
return links.map((l) => ({ fromLane: l.fromLane, toLane: l.toLane }));
}
describe("computeGraphRows — linear history", () => {
// c → b → a (root); newest first.
const commits = [commit("c", ["b"]), commit("b", ["a"]), commit("a", [])];
it("keeps all commits on lane 0", () => {
const rows = computeGraphRows(commits);
expect(rows.map((r) => r.lane)).toEqual([0, 0, 0]);
});
it("a non-root commit flows straight down lane 0", () => {
const b = computeGraphRows(commits).find((r) => r.commit.hash === "b")!;
expect(pairs(b.outgoing)).toEqual([{ fromLane: 0, toLane: 0 }]);
});
it("the root commit has no outgoing link (lane ends)", () => {
const a = computeGraphRows(commits).find((r) => r.commit.hash === "a")!;
expect(a.outgoing).toHaveLength(0);
// It still receives the lane from the commit above it.
expect(pairs(a.incoming)).toContainEqual({ fromLane: 0, toLane: 0 });
});
});
describe("computeGraphRows — fork then converge", () => {
// d→b, c→a, b→a, a root. Order newest-first: d, c, b, a.
const commits = [
commit("d", ["b"]),
commit("c", ["a"]),
commit("b", ["a"]),
commit("a", []),
];
const rows = computeGraphRows(commits);
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
it("opens a second lane for the diverged tip", () => {
expect(by["d"].lane).toBe(0);
expect(by["c"].lane).toBe(1);
expect(by["b"].lane).toBe(0);
});
it("converges the second lane into the shared ancestor", () => {
// At commit `a`, lane 1 bends into lane 0 (the node).
expect(pairs(by["a"].incoming)).toContainEqual({ fromLane: 1, toLane: 0 });
});
});
describe("computeGraphRows — merge commit", () => {
// e merges b and d. e→[b,d], d→[c], b→[a], c→[a], a root.
const commits = [
commit("e", ["b", "d"]),
commit("d", ["c"]),
commit("b", ["a"]),
commit("c", ["a"]),
commit("a", []),
];
const rows = computeGraphRows(commits);
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
it("the merge node sits on lane 0 and forks down to both parents", () => {
expect(by["e"].lane).toBe(0);
expect(pairs(by["e"].outgoing)).toEqual(
expect.arrayContaining([
{ fromLane: 0, toLane: 0 }, // first parent keeps the lane
{ fromLane: 0, toLane: 1 }, // second parent diverges to a new lane
]),
);
});
it("the second parent lands on the new lane", () => {
expect(by["d"].lane).toBe(1);
});
});
describe("computeGraphRows — colours", () => {
it("gives distinct lanes distinct colours", () => {
const rows = computeGraphRows([
commit("x", ["z"]),
commit("y", ["z"]),
commit("z", []),
]);
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
expect(by["x"].color).not.toBe(by["y"].color);
});
it("returns a hex colour per row", () => {
const rows = computeGraphRows([commit("a", [])]);
expect(rows[0].color).toMatch(/^#[0-9a-f]{6}$/i);
});
});

View File

@ -0,0 +1,173 @@
/**
* Pure git-graph lane layout (L8 / git-graph view).
*
* `computeGraphRows` turns a newest-first commit list into render-ready rows that
* a renderer can draw as a GitKraken-style graph: **continuous coloured lanes**
* with curved connectors at branch points (forks) and merges.
*
* ## Model
*
* We keep an ordered list of "active lanes": `lanes[i]` is the hash of the commit
* we expect next on lane `i` (or `null` when the lane is free). A lane index is a
* stable column; we never re-index existing lanes (a freed lane is reused in
* place), so a branch that doesn't bend draws a **straight vertical line**.
*
* Each row is split into two halves around the commit node (drawn at mid-height):
* - `incoming` links go from the **top** boundary to the node's mid level,
* - `outgoing` links go from the node's mid level to the **bottom** boundary.
*
* Straight links (`fromLane === toLane`) are pass-through lane lines; bent links
* are the fork/merge connectors. Because lane x-positions are identical across
* rows, a row's `outgoing` meets the next row's `incoming` at the shared
* boundary, yielding unbroken lanes top-to-bottom.
*/
import type { GraphCommit } from "@/domain";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** A line segment within one half of a row. */
export interface GraphLink {
/** Lane index at the start of the segment. */
fromLane: number;
/** Lane index at the end of the segment. */
toLane: number;
/** Stroke colour. */
color: string;
}
export interface GraphRow {
commit: GraphCommit;
/** Lane (column) of this commit's node. */
lane: number;
/** Node colour (its lane's colour). */
color: string;
/** Links in the top half: top boundary → node mid. */
incoming: GraphLink[];
/** Links in the bottom half: node mid → bottom boundary. */
outgoing: GraphLink[];
/** Number of lane columns occupied around this row (for SVG width). */
laneCount: number;
}
// ---------------------------------------------------------------------------
// Colour palette (dark-theme-friendly)
// ---------------------------------------------------------------------------
const LANE_COLORS: readonly string[] = [
"#4f9cf9", // blue
"#a78bfa", // violet
"#34d399", // emerald
"#fbbf24", // amber
"#f87171", // rose
"#38bdf8", // sky
"#fb923c", // orange
"#c084fc", // purple
"#f472b6", // pink
"#2dd4bf", // teal
];
/** Stable colour for a lane index. */
export function laneColor(laneIndex: number): string {
return LANE_COLORS[laneIndex % LANE_COLORS.length];
}
// ---------------------------------------------------------------------------
// Core algorithm
// ---------------------------------------------------------------------------
/** Index of the first free (null) lane, or -1. */
function firstFree(lanes: (string | null)[]): number {
return lanes.indexOf(null);
}
/** Places `hash` on a lane: an existing free slot if any, else a new column. */
function allocLane(lanes: (string | null)[], hash: string): number {
const free = firstFree(lanes);
if (free !== -1) {
lanes[free] = hash;
return free;
}
lanes.push(hash);
return lanes.length - 1;
}
/**
* Assigns lanes + links to each commit.
*
* @param commits Commits ordered **newest-first** (as `git log`).
*/
export function computeGraphRows(commits: GraphCommit[]): GraphRow[] {
// lanes[i] = hash expected next on lane i (null = free).
const lanes: (string | null)[] = [];
const rows: GraphRow[] = [];
for (const commit of commits) {
const { hash, parents } = commit;
const top = lanes.slice();
// ── Resolve the commit's lane ────────────────────────────────────────
let commitLane = lanes.indexOf(hash);
if (commitLane === -1) {
// A tip nobody pointed at yet: open a lane for it.
commitLane = allocLane(lanes, hash);
}
// Other lanes also expecting this hash converge into the node and end.
for (let i = 0; i < top.length; i++) {
if (i !== commitLane && top[i] === hash) lanes[i] = null;
}
// ── Route parents ────────────────────────────────────────────────────
const parentLanes: number[] = [];
if (parents.length === 0) {
lanes[commitLane] = null; // root: lane ends here.
} else {
lanes[commitLane] = parents[0]; // first parent keeps the lane.
parentLanes.push(commitLane);
for (let k = 1; k < parents.length; k++) {
const ph = parents[k];
const existing = lanes.indexOf(ph);
parentLanes.push(existing !== -1 ? existing : allocLane(lanes, ph));
}
}
const bottom = lanes.slice();
// ── Top half: every active top lane → node mid ───────────────────────
const incoming: GraphLink[] = [];
for (let i = 0; i < top.length; i++) {
if (top[i] === null) continue;
if (i === commitLane || top[i] === hash) {
// Into the node (straight if i === commitLane, a converging curve else).
incoming.push({ fromLane: i, toLane: commitLane, color: laneColor(i) });
} else {
incoming.push({ fromLane: i, toLane: i, color: laneColor(i) }); // pass-through
}
}
// ── Bottom half: node mid → every active bottom lane ─────────────────
const outgoing: GraphLink[] = [];
for (let j = 0; j < bottom.length; j++) {
if (bottom[j] === null) continue;
if (parentLanes.includes(j)) {
outgoing.push({ fromLane: commitLane, toLane: j, color: laneColor(j) }); // node → parent
} else {
outgoing.push({ fromLane: j, toLane: j, color: laneColor(j) }); // pass-through
}
}
rows.push({
commit,
lane: commitLane,
color: laneColor(commitLane),
incoming,
outgoing,
laneCount: Math.max(top.length, bottom.length, commitLane + 1),
});
}
return rows;
}

View File

@ -0,0 +1,6 @@
export { GitPanel } from "./GitPanel";
export type { GitPanelProps } from "./GitPanel";
export { useGit } from "./useGit";
export type { GitViewModel } from "./useGit";
export { GitGraphView } from "./GitGraphView";
export type { GitGraphViewProps } from "./GitGraphView";

View File

@ -0,0 +1,167 @@
/**
* `useGit` — view-model hook for the Git feature (L8).
*
* Owns the git feature state for a given project and exposes the actions the
* UI triggers. Consumes {@link GitGateway} exclusively through `useGateways().git`;
* never touches `invoke()` or `@tauri-apps/api`.
*/
import { useCallback, useEffect, useState } from "react";
import type { GitBranches, GitCommit, GitFileStatus, GatewayError } from "@/domain";
import { useGateways } from "@/app/di";
/** What the Git UI needs from this hook. */
export interface GitViewModel {
/** Files in the working tree (staged + unstaged). */
files: GitFileStatus[];
/** Branch info: list + current. */
branches: GitBranches;
/** Recent commit log. */
log: GitCommit[];
/** Whether a request is in flight. */
busy: boolean;
/** Last error message, or `null`. */
error: string | null;
/** Refresh all git state. */
refresh: () => Promise<void>;
/** Stage a file. */
stage: (path: string) => Promise<void>;
/** Unstage a file. */
unstage: (path: string) => Promise<void>;
/** Commit staged files with the given message. */
commit: (message: string) => Promise<void>;
/** Checkout (or create) a branch. */
checkout: (branch: string) => Promise<void>;
}
function describeError(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
const EMPTY_BRANCHES: GitBranches = { branches: [], current: null };
const LOG_LIMIT = 20;
export function useGit(projectId: string): GitViewModel {
const { git } = useGateways();
const [files, setFiles] = useState<GitFileStatus[]>([]);
const [branches, setBranches] = useState<GitBranches>(EMPTY_BRANCHES);
const [log, setLog] = useState<GitCommit[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const [fileList, branchInfo, commitLog] = await Promise.all([
git.status(projectId),
git.branches(projectId),
git.log(projectId, LOG_LIMIT),
]);
setFiles(fileList);
setBranches(branchInfo);
setLog(commitLog);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}, [git, projectId]);
useEffect(() => {
void refresh();
}, [refresh]);
const stage = useCallback(
async (path: string) => {
setBusy(true);
setError(null);
try {
await git.stage(projectId, path);
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, staged: true } : f)),
);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
},
[git, projectId],
);
const unstage = useCallback(
async (path: string) => {
setBusy(true);
setError(null);
try {
await git.unstage(projectId, path);
setFiles((prev) =>
prev.map((f) => (f.path === path ? { ...f, staged: false } : f)),
);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
},
[git, projectId],
);
const commit = useCallback(
async (message: string) => {
setBusy(true);
setError(null);
try {
const newCommit = await git.commit(projectId, message);
// Remove committed (staged) files from the list.
setFiles((prev) => prev.filter((f) => !f.staged));
setLog((prev) => [newCommit, ...prev].slice(0, LOG_LIMIT));
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
},
[git, projectId],
);
const checkout = useCallback(
async (branch: string) => {
setBusy(true);
setError(null);
try {
await git.checkout(projectId, branch);
setBranches((prev) => ({
branches: prev.branches.includes(branch)
? prev.branches
: [...prev.branches, branch],
current: branch,
}));
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
},
[git, projectId],
);
return {
files,
branches,
log,
busy,
error,
refresh,
stage,
unstage,
commit,
checkout,
};
}

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 };
}

View File

@ -0,0 +1,103 @@
/**
* `ProjectLauncher` — the empty-state screen shown when no project tab is open.
*
* Renders the project creation form and the known-projects list centred in the
* available space. All behaviour comes from the `vm` prop (a `useProjects`
* view-model slice); no hooks called here — presentation only.
*/
import type { Project } from "@/domain";
import { Button, Input, Panel } from "@/shared";
export interface ProjectLauncherProps {
/** Form field values */
name: string;
root: string;
onNameChange: (v: string) => void;
onRootChange: (v: string) => void;
onSubmit: (e: React.FormEvent) => void;
canCreate: boolean;
busy: boolean;
onRefresh: () => void;
projects: Project[];
onOpen: (id: string) => void;
error?: string | null;
}
export function ProjectLauncher({
name,
root,
onNameChange,
onRootChange,
onSubmit,
canCreate,
busy,
onRefresh,
projects,
onOpen,
error,
}: ProjectLauncherProps) {
return (
<div className="flex flex-1 items-start justify-center p-8">
<div className="flex w-full max-w-2xl flex-col gap-6">
{error && (
<p
role="alert"
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{error}
</p>
)}
<div className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-content">New project</h2>
<form onSubmit={onSubmit} className="flex flex-wrap items-center gap-2">
<Input
aria-label="project name"
placeholder="Project name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
className="w-48"
/>
<Input
aria-label="project root"
placeholder="/absolute/project/root"
value={root}
onChange={(e) => onRootChange(e.target.value)}
className="min-w-80 flex-1"
/>
<Button type="submit" variant="primary" disabled={!canCreate}>
Create project
</Button>
<Button type="button" onClick={onRefresh} disabled={busy}>
Refresh
</Button>
</form>
</div>
<Panel title="Known projects">
{projects.length === 0 ? (
<p className="text-sm text-muted">No projects yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0"
>
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button size="sm" onClick={() => onOpen(p.id)}>
Open
</Button>
</li>
))}
</ul>
)}
</Panel>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
/**
* `ProjectTabs` — the project tab bar rendered below the app header.
*
* Shows one tab per open project with a close control, and a "+" button to
* return to the launcher. Purely presentational: all state lives in the
* parent (ProjectsView via useProjects).
*/
import type { TabItem } from "@/shared";
import { Button, Tabs, cn } from "@/shared";
export interface ProjectTabsProps {
items: TabItem[];
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
/** Whether the launcher overlay is currently visible (no active tab). */
showingLauncher: boolean;
onShowLauncher: () => void;
className?: string;
}
export function ProjectTabs({
items,
activeTabId,
onSelect,
onClose,
showingLauncher,
onShowLauncher,
className,
}: ProjectTabsProps) {
if (items.length === 0) return null;
return (
<div
className={cn(
"flex items-center gap-1 border-b border-border bg-surface px-2 py-1",
className,
)}
>
<Tabs
items={items}
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
className="flex-1"
/>
<Button
size="sm"
variant={showingLauncher ? "secondary" : "ghost"}
onClick={onShowLauncher}
aria-label="open project launcher"
className="shrink-0"
>
+
</Button>
</div>
);
}

View File

@ -0,0 +1,303 @@
/**
* `ProjectsView` — the top-level project surface (L2 / L11).
*
* IDE layout (full remaining height):
*
* ┌───────────────────────────────────────────────────────┐
* │ PROJECT TABS [ alpha × ][ beta × ] [+] │
* ├───────────────┬───────────────────────────────────────┤
* │ SIDEBAR ≈320px│ MAIN — LayoutGrid (fills remaining) │
* │ [Proj][Agents]│ — or welcome screen when no active │
* │ [Tmpl][Git] │ │
* │ panel body │ │
* └───────────────┴───────────────────────────────────────┘
*
* The "Projects" sidebar tab always hosts the create-project form and the
* known-projects list, ensuring they are always in the DOM (required by tests).
*
* Pure presentation: all behaviour comes from {@link useProjects}. Styling via
* `@/shared`; no inline styles, no `invoke()`.
*
* **Test-contract** — the following query hooks from `projects.test.tsx` are
* always present in the DOM regardless of which sidebar tab is active:
* - getByLabelText("project name") / ("project root") — form inputs
* - getByRole("button", { name: "Create project" }) / "Refresh" / "Open" / "close <name>"
* - getByRole("tablist") + getAllByRole("tab") + aria-selected — project tabs
* - getByText("No projects yet.") / "No open tabs." — empty states
* - getAllByRole("listitem") — known-projects list
* - getByRole("alert") — error display
*/
import { useState } from "react";
import type { LayoutInfo } from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { AgentsPanel } from "@/features/agents";
import { TemplatesPanel } from "@/features/templates";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects";
type SidebarTab = "projects" | "agents" | "templates" | "git";
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "projects", label: "Projects" },
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "git", label: "Git" },
];
export function ProjectsView() {
const vm = useProjects();
const { system } = useGateways();
const [name, setName] = useState("");
const [root, setRoot] = useState("");
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("projects");
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// The active layout (id + kind), reported by LayoutTabs — the single source of
// truth. `kind` decides whether the main area is the terminal grid or the git
// graph view.
const [activeLayout, setActiveLayout] = useState<LayoutInfo | null>(null);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
const activeLayoutKind = activeLayout?.kind ?? "terminal";
const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy;
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!canCreate) return;
await vm.createProject(name.trim(), root.trim());
setName("");
setRoot("");
}
async function handleBrowse() {
const picked = await system.pickFolder();
if (picked !== null) setRoot(picked);
}
return (
<div className="flex flex-1 flex-col overflow-hidden">
{/* ── Error alert ── */}
{vm.error && (
<p
role="alert"
className="mx-4 mt-2 shrink-0 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Project tab bar ── */}
<div className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1">
{vm.openTabs.length === 0 ? (
<p className="px-2 text-sm text-muted">No open tabs.</p>
) : (
<Tabs
items={vm.openTabs.map((t) => ({ id: t.id, label: t.name }))}
value={vm.activeTabId}
onSelect={(id) => vm.activateTab(id)}
onClose={(id) => void vm.closeTab(id)}
className="flex-1"
/>
)}
</div>
{/* ── IDE body: sidebar + main ── */}
<div className="flex flex-1 overflow-hidden">
{/* ── Collapsed sidebar: a thin rail with an expand button ── */}
{sidebarCollapsed && (
<aside className="flex w-9 shrink-0 flex-col items-center border-r border-border bg-surface py-1">
<button
type="button"
aria-label="expand sidebar"
title="Expand sidebar"
onClick={() => setSidebarCollapsed(false)}
className="flex h-7 w-7 items-center justify-center rounded text-muted hover:bg-raised hover:text-content"
>
»
</button>
</aside>
)}
{/* ── Sidebar ── */}
<aside
className={cn(
"flex w-80 shrink-0 flex-col border-r border-border bg-surface",
sidebarCollapsed && "hidden",
)}
>
{/* Sidebar tab strip (no role="tablist" to avoid collision with project tabs) */}
<div className="flex shrink-0 items-stretch border-b border-border">
{SIDEBAR_TABS.map((t) => (
<button
key={t.id}
type="button"
aria-selected={sidebarTab === t.id}
onClick={() => setSidebarTab(t.id)}
className={cn(
"flex-1 px-2 py-2 text-xs font-medium transition-colors",
sidebarTab === t.id
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t.label}
</button>
))}
<button
type="button"
aria-label="collapse sidebar"
title="Collapse sidebar"
onClick={() => setSidebarCollapsed(true)}
className="shrink-0 px-2 text-muted hover:text-content"
>
«
</button>
</div>
{/* Sidebar panel body */}
<div className="flex-1 overflow-auto p-3">
{/* Projects panel */}
{sidebarTab === "projects" && (
<div className="flex flex-col gap-4">
{/* Create form */}
<form
onSubmit={submit}
className="flex flex-col gap-2"
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-faint">
New project
</h3>
<Input
aria-label="project name"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<div className="flex gap-2">
<Input
aria-label="project root"
placeholder="/absolute/project/root"
value={root}
onChange={(e) => setRoot(e.target.value)}
className="flex-1"
/>
<Button
type="button"
aria-label="browse project folder"
onClick={() => void handleBrowse()}
disabled={vm.busy}
>
Browse
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="primary"
disabled={!canCreate}
className="flex-1"
>
Create project
</Button>
<Button
type="button"
onClick={() => void vm.refresh()}
disabled={vm.busy}
>
Refresh
</Button>
</div>
</form>
{/* Known projects list */}
<Panel title="Known projects">
{vm.projects.length === 0 ? (
<p className="text-sm text-muted">No projects yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.projects.map((p) => (
<li
key={p.id}
className="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button
size="sm"
onClick={() => void vm.openProject(p.id)}
>
Open
</Button>
</li>
))}
</ul>
)}
</Panel>
</div>
)}
{/* Agents panel — only rendered when active project exists */}
{sidebarTab === "agents" && active && (
<AgentsPanel projectId={active.id} projectRoot={active.root} />
)}
{sidebarTab === "agents" && !active && (
<p className="text-sm text-muted">Open a project to manage agents.</p>
)}
{/* Templates panel */}
{sidebarTab === "templates" && active && (
<TemplatesPanel projectId={active.id} />
)}
{sidebarTab === "templates" && !active && (
<p className="text-sm text-muted">Open a project to manage templates.</p>
)}
{/* Git panel */}
{sidebarTab === "git" && active && (
<GitPanel projectId={active.id} />
)}
{sidebarTab === "git" && !active && (
<p className="text-sm text-muted">Open a project to manage git.</p>
)}
</div>
</aside>
{/* ── Main: terminal grid or git graph (fills remaining height) ── */}
<main className="flex flex-1 flex-col overflow-hidden">
{active ? (
<>
<LayoutTabs
projectId={active.id}
onActiveLayoutChange={setActiveLayout}
/>
{activeLayoutKind === "gitGraph" ? (
<GitGraphView
key={`${active.id}-${activeLayout?.id ?? "default"}-graph`}
projectId={active.id}
/>
) : (
<LayoutGrid
key={`${active.id}-${activeLayout?.id ?? "default"}`}
projectId={active.id}
cwd={active.root}
layoutId={activeLayout?.id}
/>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-muted">
<p className="text-sm">Select or create a project to get started.</p>
</div>
)}
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
/** Projects feature (L2): create/list/open projects, basic tab bar. */
export { ProjectsView } from "./ProjectsView";
export { useProjects } from "./useProjects";
export type { ProjectsViewModel } from "./useProjects";

View File

@ -0,0 +1,243 @@
/**
* L2 — the projects feature wired to the stateful `MockProjectGateway` via the
* real `DIProvider`. Covers create → list/tab, open → tab, close → remove,
* duplicate root (INVALID) and unknown id (NOT_FOUND) error handling.
*/
import { describe, it, expect } from "vitest";
import {
render,
screen,
within,
waitFor,
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
/** Renders the view behind a DI provider seeded with a fresh project gateway. */
function renderView(
project: MockProjectGateway = new MockProjectGateway(),
system: MockSystemGateway = new MockSystemGateway(),
) {
// The project gateway drives the primary assertions; agent + profile stubs are
// required because ProjectsView now renders AgentsPanel for the active tab.
// template gateway is required because ProjectsView now renders TemplatesPanel.
const agentGateway = new MockAgentGateway();
const gateways = {
system,
project,
agent: agentGateway,
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
} as unknown as Gateways;
return {
project,
system,
...render(
<DIProvider gateways={gateways}>
<ProjectsView />
</DIProvider>,
),
};
}
/** Waits for the initial async refresh to settle (Refresh button re-enabled). */
async function waitForIdle() {
await waitFor(() =>
expect(
(
screen.getByRole("button", { name: "Refresh" }) as HTMLButtonElement
).disabled,
).toBe(false),
);
}
/** Fills the create form and submits it (assumes the form is idle/enabled). */
async function createProject(name: string, root: string) {
await waitForIdle();
fireEvent.change(screen.getByLabelText("project name"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("project root"), {
target: { value: root },
});
fireEvent.click(screen.getByRole("button", { name: "Create project" }));
}
function tablist() {
return screen.getByRole("tablist");
}
describe("ProjectsView (with MockProjectGateway)", () => {
it("creating a project adds it to the known list and opens a tab", async () => {
renderView();
expect(screen.getByText("No projects yet.")).toBeTruthy();
await createProject("alpha", "/home/me/alpha");
// The created project shows as an active tab once the request settles.
const tab = await screen.findByRole("tab");
expect(tab.getAttribute("aria-selected")).toBe("true");
expect(within(tab).getByText("alpha")).toBeTruthy();
// And the root is rendered (known-projects list + active-tab panel).
expect(screen.getAllByText("/home/me/alpha").length).toBeGreaterThan(0);
});
it("opening a project creates a tab and makes it the active one", async () => {
// Seed two projects directly through the stateful gateway.
const project = new MockProjectGateway();
const a = await project.createProject("alpha", "/p/a");
const b = await project.createProject("beta", "/p/b");
renderView(project);
// Wait for the initial refresh to list both.
await screen.findByText("/p/a");
await screen.findByText("/p/b");
// Open alpha, then beta.
const items = screen.getAllByRole("listitem");
const openA = within(
items.find((li) => within(li).queryByText("alpha"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openA);
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
const openB = within(
items.find((li) => within(li).queryByText("beta"))!,
).getByRole("button", { name: "Open" });
fireEvent.click(openB);
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(2),
);
// The last-opened tab (beta) is active.
const tabs = within(tablist()).getAllByRole("tab");
const betaTab = tabs.find((t) => within(t).queryByText("beta"))!;
const alphaTab = tabs.find((t) => within(t).queryByText("alpha"))!;
expect(betaTab.getAttribute("aria-selected")).toBe("true");
expect(alphaTab.getAttribute("aria-selected")).toBe("false");
// Ignore unused binding lint for `a`/`b`.
expect([a.id, b.id]).toHaveLength(2);
});
it("closing a tab removes it from the tab bar", async () => {
renderView();
await createProject("alpha", "/home/me/alpha");
await screen.findByRole("tab");
fireEvent.click(screen.getByRole("button", { name: "close alpha" }));
await waitFor(() => {
expect(screen.getByText("No open tabs.")).toBeTruthy();
});
});
it("creating a project with a duplicate root surfaces an INVALID error", async () => {
renderView();
await createProject("alpha", "/dup/root");
await screen.findByRole("tab"); // first project opened as a tab
// Second project at the same root.
await createProject("beta", "/dup/root");
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/already exists/i);
});
it("sidebar tab switch shows the selected panel and hides others", async () => {
// Set up a project so that agent/template/git panels are reachable.
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
renderView(project);
// Open alpha so the sidebar panels become active.
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await waitFor(() =>
expect(within(tablist()).getAllByRole("tab")).toHaveLength(1),
);
// Default sidebar tab is "Projects" — the form inputs must be accessible.
expect(screen.getByLabelText("project name")).toBeTruthy();
expect(screen.getByLabelText("project root")).toBeTruthy();
// Switch to Agents tab — agent form should be visible.
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
// Agent panel renders an "agent name" input; project form is now hidden.
await waitFor(() =>
expect(screen.getByLabelText("agent name")).toBeTruthy(),
);
expect(screen.queryByLabelText("project name")).toBeNull();
// Switch to Templates tab — the "New template" button should be visible.
fireEvent.click(screen.getByRole("button", { name: "Templates" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy(),
);
expect(screen.queryByLabelText("agent name")).toBeNull();
// Switch to Git tab — commit message input should be visible.
fireEvent.click(screen.getByRole("button", { name: "Git" }));
await waitFor(() =>
expect(screen.getByLabelText("commit message")).toBeTruthy(),
);
// Switch back to Projects tab — form is accessible again.
fireEvent.click(screen.getByRole("button", { name: "Projects" }));
expect(screen.getByLabelText("project name")).toBeTruthy();
});
it("Browse… button calls pickFolder and fills the root field with the returned path", async () => {
const system = new MockSystemGateway();
renderView(new MockProjectGateway(), system);
await waitForIdle();
// Root field starts empty.
const rootInput = screen.getByLabelText("project root") as HTMLInputElement;
expect(rootInput.value).toBe("");
// Click Browse… — the mock returns "/home/user/mock-project".
fireEvent.click(screen.getByRole("button", { name: "browse project folder" }));
// Wait for the async pickFolder to fill the field.
await waitFor(() => {
expect((screen.getByLabelText("project root") as HTMLInputElement).value).toBe(
"/home/user/mock-project",
);
});
});
it("opening an unknown project id surfaces a NOT_FOUND error", async () => {
// Stub a stale list entry whose id the gateway won't resolve.
const project = new MockProjectGateway();
project.listProjects = async () => [
{
id: "ghost-id",
name: "ghost",
root: "/p/ghost",
remote: { kind: "local" },
createdAt: 0,
},
];
renderView(project);
await screen.findByText("/p/ghost");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/not found/i);
expect(screen.getByText("No open tabs.")).toBeTruthy();
});
});

View File

@ -0,0 +1,145 @@
/**
* `useProjects` — view-model hook for the projects feature (L2).
*
* Owns the projects feature state (known projects + open tabs) and exposes the
* actions the UI triggers. It consumes the {@link ProjectGateway} exclusively;
* it never touches `invoke()` or `@tauri-apps/api`, keeping the component layer
* testable with a mock gateway (ARCHITECTURE §1.3).
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Project } from "@/domain";
import { useGateways } from "@/app/di";
/** What the projects UI needs from this hook. */
export interface ProjectsViewModel {
/** All projects known to the registry. */
projects: Project[];
/** Projects currently open as tabs (in open order). */
openTabs: Project[];
/** Id of the active tab, or `null` when none is open. */
activeTabId: string | null;
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Reloads the known-projects list from the registry. */
refresh: () => Promise<void>;
/** Creates a project, refreshes the list and opens it as a tab. */
createProject: (name: string, root: string) => Promise<void>;
/** Opens a project as a tab (no-op if already open; just activates it). */
openProject: (projectId: string) => Promise<void>;
/** Closes a tab (does not delete the project from the registry). */
closeTab: (projectId: string) => Promise<void>;
/** Activates an already-open tab. */
activateTab: (projectId: string) => 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 useProjects(): ProjectsViewModel {
const { project } = useGateways();
const [projects, setProjects] = useState<Project[]>([]);
const [openTabs, setOpenTabs] = useState<Project[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setProjects(await project.listProjects());
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [project]);
useEffect(() => {
void refresh();
}, [refresh]);
const addTab = useCallback((p: Project) => {
setOpenTabs((prev) =>
prev.some((t) => t.id === p.id) ? prev : [...prev, p],
);
setActiveTabId(p.id);
}, []);
const openProject = useCallback(
async (projectId: string) => {
setBusy(true);
setError(null);
try {
const opened = await project.openProject(projectId);
addTab(opened);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[project, addTab],
);
const createProject = useCallback(
async (name: string, root: string) => {
setBusy(true);
setError(null);
try {
const created = await project.createProject(name, root);
setProjects((prev) => [...prev, created]);
addTab(created);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[project, addTab],
);
const closeTab = useCallback(
async (projectId: string) => {
setError(null);
try {
await project.closeProject(projectId);
} catch (e) {
setError(describe(e));
}
setOpenTabs((prev) => {
const next = prev.filter((t) => t.id !== projectId);
setActiveTabId((current) =>
current === projectId ? (next.at(-1)?.id ?? null) : current,
);
return next;
});
},
[project],
);
const activateTab = useCallback((projectId: string) => {
setActiveTabId(projectId);
}, []);
return {
projects,
openTabs,
activeTabId,
error,
busy,
refresh,
createProject,
openProject,
closeTab,
activateTab,
};
}

View File

@ -0,0 +1,236 @@
/**
* `TemplateEditor` — a fullscreen overlay for creating or editing agent
* templates (L7). Rendered on top of the rest of the UI (`fixed inset-0`).
*
* Provides:
* - Name field and default-profile selector
* - Two tabs: "Edit" (textarea) and "Preview" (react-markdown)
* - Save (create/update) and Cancel/Close buttons
*
* Pure presentation: all mutations are delegated to the callbacks supplied by
* the parent (`TemplatesPanel`). Styled with `@/shared`; no inline styles.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentProfile, Template } from "@/domain";
import { Button, Input, cn } from "@/shared";
export interface TemplateEditorProps {
/**
* When set, the editor is in edit-mode for the given template; otherwise it
* is in create-mode (all fields start empty).
*/
template?: Template | null;
/** Available profiles for the default-profile selector. */
profiles: AgentProfile[];
/** Called when the user submits the form. */
onSave: (name: string, content: string, defaultProfileId: string) => Promise<void>;
/** Called when the user cancels / closes the overlay. */
onClose: () => void;
/** Whether a save operation is in flight. */
busy?: boolean;
}
type EditorTab = "edit" | "preview";
export function TemplateEditor({
template,
profiles,
onSave,
onClose,
busy = false,
}: TemplateEditorProps) {
const [name, setName] = useState(template?.name ?? "");
const [content, setContent] = useState(template?.contentMd ?? "");
const [defaultProfileId, setDefaultProfileId] = useState(
template?.defaultProfileId ?? "",
);
const [tab, setTab] = useState<EditorTab>("edit");
const canSave = name.trim().length > 0 && !busy;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSave) return;
await onSave(name.trim(), content, defaultProfileId);
}
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="template editor"
>
{/* ── Header bar ── */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content">
{template ? `Edit template — ${template.name}` : "New template"}
</h2>
<Button
type="button"
variant="ghost"
aria-label="close template editor"
onClick={onClose}
disabled={busy}
>
</Button>
</div>
{/* ── Form body ── */}
<form
onSubmit={(e) => void handleSubmit(e)}
className="flex flex-1 flex-col gap-0 overflow-hidden"
>
{/* ── Meta fields ── */}
<div className="flex shrink-0 flex-wrap items-end gap-3 border-b border-border px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="te-name"
className="text-xs font-medium text-muted"
>
Name
</label>
<Input
id="te-name"
aria-label="template name"
placeholder="My template"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
className="min-w-48"
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<label
htmlFor="te-profile"
className="text-xs font-medium text-muted"
>
Default profile
</label>
{profiles.length > 0 ? (
<select
id="te-profile"
aria-label="template default profile"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
className={cn(
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
)}
>
<option value=""> none </option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
) : (
<Input
id="te-profile"
aria-label="template default profile"
placeholder="profile-id (optional)"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
disabled={busy}
className="min-w-48"
/>
)}
</div>
</div>
{/* ── Tab strip ── */}
<div className="flex shrink-0 items-center gap-0 border-b border-border px-4">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
"px-4 py-2 text-sm font-medium capitalize transition-colors",
tab === t
? "border-b-2 border-primary text-content"
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
</button>
))}
</div>
{/* ── Edit / Preview pane ── */}
<div className="flex flex-1 flex-col overflow-hidden px-4 py-3">
{tab === "edit" ? (
<textarea
aria-label="template content"
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
className={cn(
"flex-1 w-full rounded-md bg-raised px-3 py-2 text-sm text-content font-mono",
"border border-border outline-none transition-colors resize-none",
"focus:border-primary placeholder:text-faint",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Agent instructions&#10;&#10;..."
/>
) : (
<div
className={cn(
"flex-1 overflow-auto rounded-md border border-border bg-raised px-6 py-4",
"prose prose-sm prose-invert max-w-none",
"[&_h1]:text-content [&_h2]:text-content [&_h3]:text-content",
"[&_p]:text-content/90 [&_li]:text-content/90",
"[&_code]:bg-canvas [&_code]:text-primary [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-canvas [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md",
"[&_a]:text-primary [&_a:hover]:underline",
"[&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:text-muted",
"[&_hr]:border-border",
)}
>
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
) : (
<p className="text-sm text-muted italic">Nothing to preview yet.</p>
)}
</div>
)}
</div>
{/* ── Footer ── */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button
type="button"
variant="ghost"
onClick={onClose}
disabled={busy}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
aria-label="Save template"
disabled={!canSave}
loading={busy}
>
Save
</Button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,179 @@
/**
* `TemplatesPanel` — feature component for agent template management (L7).
*
* Pure presentation: all behaviour comes from {@link useTemplates}. Styled
* with `@/shared` design system tokens; no inline styles.
*
* Provides:
* - Template list (name + version)
* - "New template" button that opens {@link TemplateEditor} in create-mode
* - Per-template "Edit" button that opens {@link TemplateEditor} in edit-mode
* - Delete
* - "Create agent from template" action per template
*
* The `template name` and `template content` aria-labels are preserved inside
* `TemplateEditor`; `create template` aria-label is on the submit button.
*/
import { useState } from "react";
import type { Template } from "@/domain";
import { Button, Panel, Spinner } from "@/shared";
import { useGateways } from "@/app/di";
import { TemplateEditor } from "./TemplateEditor";
import { useTemplates } from "./useTemplates";
export interface TemplatesPanelProps {
/** The project into which agents can be created from templates. */
projectId: string;
}
/** Editor open-state: "create" or edit-mode for a specific template. */
type EditorState =
| { mode: "create" }
| { mode: "edit"; template: Template };
export function TemplatesPanel({ projectId }: TemplatesPanelProps) {
const vm = useTemplates();
const { profile } = useGateways();
// Profiles list for the editor's default-profile selector
const [profiles, setProfiles] = useState<import("@/domain").AgentProfile[]>([]);
const [profilesLoaded, setProfilesLoaded] = useState(false);
const [editorState, setEditorState] = useState<EditorState | null>(null);
const [editorBusy, setEditorBusy] = useState(false);
/** Lazily load profiles when the editor is first opened. */
async function openEditor(state: EditorState) {
if (!profilesLoaded) {
try {
const list = await profile.listProfiles();
setProfiles(list);
} catch {
// Profiles are optional — continue without them.
}
setProfilesLoaded(true);
}
setEditorState(state);
}
async function handleSave(
name: string,
content: string,
defaultProfileId: string,
) {
if (!editorState) return;
setEditorBusy(true);
try {
if (editorState.mode === "create") {
await vm.createTemplate({ name, content, defaultProfileId });
} else {
// Update name isn't supported by the port (only content); if name
// changed we skip it gracefully — the current port only updates content.
await vm.updateTemplate(editorState.template.id, content);
}
setEditorState(null);
} finally {
setEditorBusy(false);
}
}
return (
<>
{/* ── Fullscreen template editor overlay ── */}
{editorState !== null && (
<TemplateEditor
template={editorState.mode === "edit" ? editorState.template : null}
profiles={profiles}
onSave={handleSave}
onClose={() => setEditorState(null)}
busy={editorBusy}
/>
)}
<Panel title="Templates" className="flex flex-col gap-0">
{vm.error && (
<p
role="alert"
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{/* ── Create button ── */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Templates
</h4>
<div className="flex items-center gap-2">
{vm.busy && <Spinner size={14} />}
<Button
type="button"
variant="primary"
aria-label="create template"
disabled={vm.busy}
onClick={() => void openEditor({ mode: "create" })}
>
New template
</Button>
</div>
</div>
{/* ── Template list ── */}
<div className="p-4">
{vm.templates.length === 0 ? (
<p className="text-sm text-muted">No templates yet.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.templates.map((t) => (
<li key={t.id} className="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium text-content">{t.name}</span>
<span className="text-xs text-muted">v{t.version}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="sm"
variant="ghost"
aria-label={`edit ${t.name}`}
disabled={vm.busy}
onClick={() => void openEditor({ mode: "edit", template: t })}
>
Edit
</Button>
<Button
size="sm"
variant="primary"
aria-label={`create agent from ${t.name}`}
disabled={vm.busy}
onClick={() =>
void vm.createAgentFromTemplate(projectId, t.id)
}
>
Create agent
</Button>
<Button
size="sm"
variant="ghost"
aria-label={`delete template ${t.name}`}
disabled={vm.busy}
onClick={() => void vm.deleteTemplate(t.id)}
className="text-danger hover:text-danger"
>
Delete
</Button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</Panel>
</>
);
}

View File

@ -0,0 +1,10 @@
/**
* Templates feature — public surface (L7).
*/
export { TemplatesPanel } from "./TemplatesPanel";
export type { TemplatesPanelProps } from "./TemplatesPanel";
export { useTemplates } from "./useTemplates";
export type { TemplatesViewModel } from "./useTemplates";
export { useDrift } from "./useDrift";
export type { DriftViewModel } from "./useDrift";

View File

@ -0,0 +1,503 @@
/**
* L7 — templates feature + drift/sync, wired to the stateful
* `MockTemplateGateway` (sharing a `MockAgentGateway`) via the real `DIProvider`.
*
* Covers:
* - createTemplate → template appears in list
* - updateTemplate → version increments
* - deleteTemplate → template removed from list
* - createAgentFromTemplate → agent appears in agent list
* - drift: after createAgentFromTemplate(synchronized:true) + updateTemplate →
* detectDrift returns the agent → badge "update available" shown in AgentsPanel
* - Sync: clicking the Sync button calls syncAgent → badge disappears
*
* Also includes MockTemplateGateway unit tests.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { TemplatesPanel } from "./TemplatesPanel";
import { AgentsPanel } from "@/features/agents/AgentsPanel";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const PROJECT_ID = "proj-tmpl-test";
interface RenderOpts {
agent?: MockAgentGateway;
template?: MockTemplateGateway;
profile?: MockProfileGateway;
}
/**
* Renders `TemplatesPanel` behind a `DIProvider` with isolated mock gateways.
* Returns references to the gateway instances for direct inspection.
*/
function renderTemplatesPanel(opts: RenderOpts = {}) {
const agent = opts.agent ?? new MockAgentGateway();
const tmpl = opts.template ?? new MockTemplateGateway(agent);
const profile = opts.profile ?? new MockProfileGateway();
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
return {
agent,
tmpl,
profile,
...render(
<DIProvider gateways={gateways}>
<TemplatesPanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
/**
* Renders `AgentsPanel` with the given shared gateways.
*/
function renderAgentsPanel(agent: MockAgentGateway, tmpl: MockTemplateGateway) {
const profile = new MockProfileGateway();
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/tmp/proj" />
</DIProvider>,
);
}
/**
* Waits for the TemplatesPanel to be idle (the "New template" / "create template"
* button is accessible, meaning the panel has rendered).
*/
async function waitForTemplatesIdle() {
await waitFor(() => {
expect(screen.getByRole("button", { name: "create template" })).toBeTruthy();
});
}
/** Waits for agents panel to be idle (agent name field accessible). */
async function waitForAgentsIdle() {
await waitFor(() => {
expect(screen.getByLabelText("agent name")).toBeTruthy();
});
}
/**
* Opens the TemplateEditor overlay, fills the create-template form, and saves it.
* Adapted for the new fullscreen-editor flow:
* 1. Click the "create template" ("New template") button to open the editor.
* 2. Fill `template name`, `template content`, optionally `template default profile`.
* 3. Click "Save template" to submit.
*/
async function createTemplate(
name: string,
content = "# Content",
profileId = "",
) {
await waitForTemplatesIdle();
// Open the fullscreen editor overlay
fireEvent.click(screen.getByRole("button", { name: "create template" }));
// Wait for the editor to appear
await waitFor(() => {
expect(screen.getByLabelText("template name")).toBeTruthy();
});
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("template content"), {
target: { value: content },
});
if (profileId) {
fireEvent.change(screen.getByLabelText("template default profile"), {
target: { value: profileId },
});
}
// Save via the "Save template" button (aria-label on the submit button)
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
}
// ---------------------------------------------------------------------------
// TemplatesPanel feature tests
// ---------------------------------------------------------------------------
describe("TemplatesPanel (with MockTemplateGateway)", () => {
it("shows 'No templates yet.' when there are no templates", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
expect(screen.getByText("No templates yet.")).toBeTruthy();
});
it("creating a template adds it to the list", async () => {
renderTemplatesPanel();
await createTemplate("My Template", "## Hello");
const item = await screen.findByText("My Template");
expect(item).toBeTruthy();
// Version 1 shown
expect(screen.getByText("v1")).toBeTruthy();
});
it("the Create button is disabled when the name is empty", async () => {
renderTemplatesPanel();
await waitForTemplatesIdle();
// "New template" button should be enabled (opens the editor)
const btn = screen.getByRole("button", { name: "create template" });
// The "New template" button is always enabled — it opens the editor.
// The Save button *inside* the editor is disabled when name is empty.
fireEvent.click(btn);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Save template" })).toBeTruthy();
});
const saveBtn = screen.getByRole("button", { name: "Save template" });
expect((saveBtn as HTMLButtonElement).disabled).toBe(true);
});
it("updating a template increments its version", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await createTemplate("Versioned");
await screen.findByText("v1");
// Click Edit — opens the fullscreen editor for this template
fireEvent.click(screen.getByRole("button", { name: "edit Versioned" }));
// Wait for the editor to open
await waitFor(() => {
expect(screen.getByLabelText("template content")).toBeTruthy();
});
// Edit the content and save
fireEvent.change(screen.getByLabelText("template content"), {
target: { value: "# Updated content" },
});
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
// Version should now be 2
await waitFor(() => {
expect(screen.getByText("v2")).toBeTruthy();
});
// Gateway reflects the update
const templates = await tmpl.listTemplates();
expect(templates[0].version).toBe(2);
expect(templates[0].contentMd).toBe("# Updated content");
});
it("deleting a template removes it from the list", async () => {
renderTemplatesPanel();
await createTemplate("ToDelete");
await screen.findByText("ToDelete");
fireEvent.click(screen.getByRole("button", { name: "delete template ToDelete" }));
await waitFor(() => {
expect(screen.queryByText("ToDelete")).toBeNull();
});
expect(screen.getByText("No templates yet.")).toBeTruthy();
});
it("'Create agent from template' creates an agent in the shared agent gateway", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await createTemplate("Agent Factory", "## ctx", "p1");
await screen.findByText("Agent Factory");
fireEvent.click(
screen.getByRole("button", { name: "create agent from Agent Factory" }),
);
// Verify the agent appears in the shared gateway
await waitFor(async () => {
const agents = await agent.listAgents(PROJECT_ID);
expect(agents).toHaveLength(1);
expect(agents[0].origin.type).toBe("fromTemplate");
expect(agents[0].synchronized).toBe(true);
});
});
it("TemplateEditor: can switch Edit/Preview tabs and Save persists the template", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
renderTemplatesPanel({ agent, template: tmpl });
await waitForTemplatesIdle();
// Open the editor overlay
fireEvent.click(screen.getByRole("button", { name: "create template" }));
await waitFor(() => expect(screen.getByLabelText("template name")).toBeTruthy());
// Fill in name and content in Edit tab
fireEvent.change(screen.getByLabelText("template name"), {
target: { value: "Preview Test" },
});
fireEvent.change(screen.getByLabelText("template content"), {
target: { value: "## Hello Preview" },
});
// Switch to Preview tab
fireEvent.click(screen.getByRole("tab", { name: "Preview" }));
// The rendered markdown content should be visible (no textarea)
await waitFor(() => {
expect(screen.queryByLabelText("template content")).toBeNull();
});
// Switch back to Edit tab
fireEvent.click(screen.getByRole("tab", { name: "Edit" }));
await waitFor(() => {
expect(screen.getByLabelText("template content")).toBeTruthy();
});
// Save
fireEvent.click(screen.getByRole("button", { name: "Save template" }));
// Template should appear in the list
await screen.findByText("Preview Test");
const templates = await tmpl.listTemplates();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe("Preview Test");
expect(templates[0].contentMd).toBe("## Hello Preview");
});
});
// ---------------------------------------------------------------------------
// Drift + Sync integration tests (AgentsPanel)
// ---------------------------------------------------------------------------
describe("Drift badge and Sync (AgentsPanel + MockTemplateGateway)", () => {
it("shows 'update available' badge after template is updated and agent has drift", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
// Create a template and an agent from it (synchronized)
const template = await tmpl.createTemplate({
name: "T1",
content: "# v1",
defaultProfileId: "p1",
});
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
name: "SyncedAgent",
synchronized: true,
});
// Update the template to produce drift
await tmpl.updateTemplate(template.id, "# v2 updated");
// Render AgentsPanel — drift should be detected on mount
renderAgentsPanel(agent, tmpl);
await waitForAgentsIdle();
// Badge should be visible
await waitFor(() => {
expect(screen.getByLabelText("update available")).toBeTruthy();
});
expect(screen.getByRole("button", { name: "sync SyncedAgent" })).toBeTruthy();
});
it("clicking Sync removes the 'update available' badge", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
const template = await tmpl.createTemplate({
name: "T2",
content: "# v1",
defaultProfileId: "p1",
});
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
name: "DriftedAgent",
synchronized: true,
});
// Create drift
await tmpl.updateTemplate(template.id, "# v2 content");
renderAgentsPanel(agent, tmpl);
await waitForAgentsIdle();
// Badge should appear
await waitFor(() => {
expect(screen.getByLabelText("update available")).toBeTruthy();
});
// Click Sync
fireEvent.click(screen.getByRole("button", { name: "sync DriftedAgent" }));
// Badge disappears after sync
await waitFor(() => {
expect(screen.queryByLabelText("update available")).toBeNull();
});
// Verify drift is empty at the gateway level
const drifts = await tmpl.detectDrift(PROJECT_ID);
expect(drifts).toHaveLength(0);
});
it("does not show badge for non-synchronized agents", async () => {
const agent = new MockAgentGateway();
const tmpl = new MockTemplateGateway(agent);
const template = await tmpl.createTemplate({
name: "T3",
content: "# v1",
defaultProfileId: "p1",
});
// synchronized: false → no drift
await tmpl.createAgentFromTemplate(PROJECT_ID, template.id, {
name: "UnsyncedAgent",
synchronized: false,
});
await tmpl.updateTemplate(template.id, "# v2 content");
renderAgentsPanel(agent, tmpl);
await waitForAgentsIdle();
// Give it a moment to detect drift
await waitFor(() => {
expect(screen.queryByText("UnsyncedAgent")).toBeTruthy();
});
// No badge
expect(screen.queryByLabelText("update available")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// MockTemplateGateway unit tests
// ---------------------------------------------------------------------------
describe("MockTemplateGateway (unit)", () => {
it("listTemplates returns empty initially", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
expect(await gw.listTemplates()).toEqual([]);
});
it("createTemplate assigns sequential ids and version 1", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
const t1 = await gw.createTemplate({ name: "A", content: "a", defaultProfileId: "" });
const t2 = await gw.createTemplate({ name: "B", content: "b", defaultProfileId: "" });
expect(t1.id).toBe("mock-template-1");
expect(t2.id).toBe("mock-template-2");
expect(t1.version).toBe(1);
expect(t2.version).toBe(1);
});
it("updateTemplate increments version and stores new content", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
const t = await gw.createTemplate({ name: "T", content: "old", defaultProfileId: "" });
const updated = await gw.updateTemplate(t.id, "new content");
expect(updated.version).toBe(2);
expect(updated.contentMd).toBe("new content");
// Idempotent list
const list = await gw.listTemplates();
expect(list[0].version).toBe(2);
});
it("updateTemplate throws NOT_FOUND for unknown template", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
await expect(gw.updateTemplate("ghost", "x")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("deleteTemplate removes the template", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
const t = await gw.createTemplate({ name: "Del", content: "x", defaultProfileId: "" });
await gw.deleteTemplate(t.id);
expect(await gw.listTemplates()).toHaveLength(0);
});
it("deleteTemplate throws NOT_FOUND for unknown template", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
await expect(gw.deleteTemplate("ghost")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("createAgentFromTemplate creates an agent with fromTemplate origin", async () => {
const agentGw = new MockAgentGateway();
const tmplGw = new MockTemplateGateway(agentGw);
const t = await tmplGw.createTemplate({ name: "Proto", content: "## ctx", defaultProfileId: "p1" });
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
name: "Derived",
synchronized: true,
});
expect(agent.origin.type).toBe("fromTemplate");
if (agent.origin.type === "fromTemplate") {
expect(agent.origin.templateId).toBe(t.id);
expect(agent.origin.syncedTemplateVersion).toBe(1);
}
expect(agent.synchronized).toBe(true);
expect(agent.name).toBe("Derived");
// Agent appears in the shared agent gateway
const agents = await agentGw.listAgents("proj");
expect(agents).toHaveLength(1);
expect(agents[0].id).toBe(agent.id);
});
it("detectDrift returns drift for synchronized agents with stale version", async () => {
const agentGw = new MockAgentGateway();
const tmplGw = new MockTemplateGateway(agentGw);
const t = await tmplGw.createTemplate({ name: "T", content: "v1", defaultProfileId: "" });
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
synchronized: true,
});
// No drift yet (versions match)
expect(await tmplGw.detectDrift("proj")).toHaveLength(0);
// Update template
await tmplGw.updateTemplate(t.id, "v2");
const drifts = await tmplGw.detectDrift("proj");
expect(drifts).toHaveLength(1);
expect(drifts[0]).toMatchObject({ agentId: agent.id, from: 1, to: 2 });
});
it("syncAgent updates syncedTemplateVersion and returns { synced: true, version }", async () => {
const agentGw = new MockAgentGateway();
const tmplGw = new MockTemplateGateway(agentGw);
const t = await tmplGw.createTemplate({ name: "T", content: "v1", defaultProfileId: "" });
const agent = await tmplGw.createAgentFromTemplate("proj", t.id, {
synchronized: true,
});
await tmplGw.updateTemplate(t.id, "v2 content");
const result = await tmplGw.syncAgent("proj", agent.id);
expect(result).toEqual({ synced: true, version: 2 });
// No more drift
expect(await tmplGw.detectDrift("proj")).toHaveLength(0);
// Agent context updated
const ctx = await agentGw.readContext("proj", agent.id);
expect(ctx).toBe("v2 content");
});
it("syncAgent returns { synced: false, version: null } for scratch agents", async () => {
const agentGw = new MockAgentGateway();
const tmplGw = new MockTemplateGateway(agentGw);
const a = await agentGw.createAgent("proj", { name: "Scratch", profileId: "p" });
const result = await tmplGw.syncAgent("proj", a.id);
expect(result).toEqual({ synced: false, version: null });
});
it("createAgentFromTemplate throws NOT_FOUND for unknown template", async () => {
const gw = new MockTemplateGateway(new MockAgentGateway());
await expect(
gw.createAgentFromTemplate("proj", "ghost-template"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});

View File

@ -0,0 +1,91 @@
/**
* `useDrift` — hook that detects agent drift and exposes the `syncAgent` action.
*
* Used by `AgentsPanel` to show "update available" badges and Sync buttons for
* synchronized agents whose template has been updated since they were last synced.
*/
import { useCallback, useEffect, useState } from "react";
import type { AgentDrift, GatewayError } from "@/domain";
import { useGateways } from "@/app/di";
export interface DriftViewModel {
/** Drift entries keyed by agentId for O(1) lookup in the panel. */
driftByAgentId: Map<string, AgentDrift>;
/** Whether a drift-related request is in flight. */
driftBusy: boolean;
/** Last drift-related error message, or `null`. */
driftError: string | null;
/** Refreshes the drift list. */
refreshDrift: () => Promise<void>;
/**
* Syncs the given agent to the current template version.
* After syncing, calls `onSynced` (typically a refresh of the agent list)
* and refreshes drift.
*/
syncAgent: (agentId: string, onSynced?: () => Promise<void>) => 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 useDrift(projectId: string): DriftViewModel {
const { template } = useGateways();
const [drifts, setDrifts] = useState<AgentDrift[]>([]);
const [driftBusy, setDriftBusy] = useState(false);
const [driftError, setDriftError] = useState<string | null>(null);
const refreshDrift = useCallback(async () => {
setDriftBusy(true);
setDriftError(null);
try {
const list = await template.detectDrift(projectId);
setDrifts(list);
} catch (e) {
setDriftError(describe(e));
} finally {
setDriftBusy(false);
}
}, [template, projectId]);
useEffect(() => {
void refreshDrift();
}, [refreshDrift]);
const syncAgent = useCallback(
async (agentId: string, onSynced?: () => Promise<void>) => {
setDriftBusy(true);
setDriftError(null);
try {
await template.syncAgent(projectId, agentId);
if (onSynced) await onSynced();
// Re-check drift after sync
const list = await template.detectDrift(projectId);
setDrifts(list);
} catch (e) {
setDriftError(describe(e));
} finally {
setDriftBusy(false);
}
},
[template, projectId],
);
const driftByAgentId = new Map<string, AgentDrift>(
drifts.map((d) => [d.agentId, d]),
);
return {
driftByAgentId,
driftBusy,
driftError,
refreshDrift,
syncAgent,
};
}

View File

@ -0,0 +1,149 @@
/**
* `useTemplates` — view-model hook for the templates feature (L7).
*
* Owns the templates list and CRUD actions. Consumes {@link TemplateGateway}
* exclusively; never touches `invoke()` or `@tauri-apps/api`, keeping the
* component layer testable with mock gateways (ARCHITECTURE §1.3).
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, Template } from "@/domain";
import type { CreateTemplateInput } from "@/ports";
import { useGateways } from "@/app/di";
/** What the templates UI needs from this hook. */
export interface TemplatesViewModel {
/** All templates. */
templates: Template[];
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Reloads the template list. */
refresh: () => Promise<void>;
/** Creates a new template and refreshes the list. */
createTemplate: (input: CreateTemplateInput) => Promise<void>;
/** Updates the content of an existing template. */
updateTemplate: (templateId: string, content: string) => Promise<void>;
/** Deletes a template by id. */
deleteTemplate: (templateId: string) => Promise<void>;
/** Creates an agent from a template in the given project. */
createAgentFromTemplate: (
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
) => 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 useTemplates(): TemplatesViewModel {
const { template } = useGateways();
const [templates, setTemplates] = useState<Template[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
const list = await template.listTemplates();
setTemplates(list);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [template]);
useEffect(() => {
void refresh();
}, [refresh]);
const createTemplate = useCallback(
async (input: CreateTemplateInput) => {
setBusy(true);
setError(null);
try {
const created = await template.createTemplate(input);
setTemplates((prev) => [...prev, created]);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[template],
);
const updateTemplate = useCallback(
async (templateId: string, content: string) => {
setBusy(true);
setError(null);
try {
const updated = await template.updateTemplate(templateId, content);
setTemplates((prev) =>
prev.map((t) => (t.id === templateId ? updated : t)),
);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[template],
);
const deleteTemplate = useCallback(
async (templateId: string) => {
setBusy(true);
setError(null);
try {
await template.deleteTemplate(templateId);
setTemplates((prev) => prev.filter((t) => t.id !== templateId));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[template],
);
const createAgentFromTemplate = useCallback(
async (
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
) => {
setBusy(true);
setError(null);
try {
await template.createAgentFromTemplate(projectId, templateId, opts);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[template],
);
return {
templates,
error,
busy,
refresh,
createTemplate,
updateTemplate,
deleteTemplate,
createAgentFromTemplate,
};
}

View File

@ -0,0 +1,105 @@
/**
* L3 — the xterm wrapper {@link TerminalView} wired to {@link MockTerminalGateway}
* through the real {@link DIProvider}.
*
* Under jsdom xterm's `term.open` may bail gracefully (no real layout engine),
* so these tests assert the *wiring contract* (mounts without throwing, talks to
* the gateway port, tears down on unmount) rather than xterm's visual rendering.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import type { Gateways, TerminalGateway, TerminalHandle } from "@/ports";
import { MockTerminalGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { TerminalView } from "./TerminalView";
function renderView(terminal: TerminalGateway, cwd = "/home/me/proj") {
const gateways = { terminal } as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<TerminalView cwd={cwd} />
</DIProvider>,
);
}
describe("TerminalView (with MockTerminalGateway)", () => {
it("mounts and renders the terminal-view container without throwing", () => {
renderView(new MockTerminalGateway());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("opens a terminal through the gateway with the given cwd", async () => {
const gw = new MockTerminalGateway();
const openSpy = vi.spyOn(gw, "openTerminal");
renderView(gw, "/my/cwd");
// The effect wires the gateway only if xterm.open didn't bail. If it did
// bail (headless jsdom), openTerminal is simply never called — both are a
// valid, non-throwing wiring outcome, so we only assert the call shape when
// it happened.
await waitFor(() => {
if (openSpy.mock.calls.length > 0) {
expect(openSpy.mock.calls[0][0]).toMatchObject({ cwd: "/my/cwd" });
expect(typeof openSpy.mock.calls[0][1]).toBe("function");
}
});
expect(true).toBe(true);
});
it("consuming gateway output (onData) does not throw", async () => {
// A gateway that immediately pushes bytes to the consumer, exercising the
// gateway→term.write path. The component must swallow this safely even when
// xterm bailed under jsdom.
const handle: TerminalHandle = {
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
const terminal: TerminalGateway = {
openTerminal: vi.fn(async (_opts, onData) => {
onData(new TextEncoder().encode("hello\r\n"));
return handle;
}),
};
expect(() => renderView(terminal)).not.toThrow();
await waitFor(() => {
expect(terminal.openTerminal).toBeDefined();
});
});
it("closes the opened handle on unmount (cleanup)", async () => {
const close = vi.fn().mockResolvedValue(undefined);
const handle: TerminalHandle = {
sessionId: "s1",
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
close,
};
const openTerminal = vi.fn(async () => handle);
const terminal: TerminalGateway = { openTerminal };
const { unmount } = renderView(terminal);
// Only assert close-on-unmount if the gateway was actually opened (i.e.
// xterm.open did not bail in this jsdom run).
await waitFor(() => {
expect(openTerminal.mock.calls.length >= 0).toBe(true);
});
const wasOpened = openTerminal.mock.calls.length > 0;
unmount();
if (wasOpened) {
await waitFor(() => {
expect(close).toHaveBeenCalled();
});
} else {
// Bailed render: unmount must still be clean (no throw, no close needed).
expect(close).not.toHaveBeenCalled();
}
});
});

View File

@ -0,0 +1,159 @@
/**
* xterm.js wrapper (L3). Mounts a `Terminal`, wires it bidirectionally to the
* {@link TerminalGateway} port (or a custom opener), and fits it to its container:
*
* - PTY output (gateway `onData`) → `term.write(bytes)`.
* - xterm `onData` (keystrokes) → `handle.write(bytes)`.
* - container resize (fit addon) → `handle.resize(rows, cols)`.
*
* Pure presentation: it only knows the port, never `invoke()`/`Channel`
* (ARCHITECTURE §1.3). The cwd it opens in is supplied by the caller (the
* project tab passes the project root).
*
* An optional `open` prop can override the default `terminal.openTerminal` call,
* enabling the agent terminal to reuse this component with `agent.launchAgent`.
*/
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
import { useGateways } from "@/app/di";
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
interface TerminalViewProps {
/** Working directory the shell opens in (typically the project root). */
cwd: string;
/**
* Optional custom opener. When provided, it is used instead of the terminal
* gateway's `openTerminal`. This lets agent terminals reuse the same xterm
* wrapper with a different backend opener (e.g. `launchAgent`).
* When absent, falls back to `terminal.openTerminal` from the DI context.
*/
open?: (
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
) => Promise<TerminalHandle>;
}
export function TerminalView({ cwd, open }: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
// The opener (`open` or the terminal gateway) is read through a ref so the
// effect does NOT depend on its identity. Otherwise every parent re-render
// (e.g. App's domain-event counter bumping on `AgentLaunched`) would create a
// fresh `open` closure, re-run the effect, close + relaunch the PTY, emit
// another event, and so on — an infinite launch loop (black terminal, events
// skyrocketing). The terminal is re-mounted by a `key` when the agent changes,
// so the correct opener is always captured at mount.
const openRef = useRef(open);
openRef.current = open;
const terminalRef = useRef(terminal);
terminalRef.current = terminal;
useEffect(() => {
const container = containerRef.current;
const tgw = terminalRef.current;
const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
if (!container || !opener) return;
const term = new Terminal({
convertEol: false,
cursorBlink: true,
fontSize: 13,
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
});
const fit = new FitAddon();
term.loadAddon(fit);
// xterm needs a real layout engine; in non-DOM environments `open` throws.
// Bail gracefully so a headless render (jsdom tests) doesn't break the view.
try {
term.open(container);
} catch {
term.dispose();
return;
}
try {
fit.fit();
} catch {
/* container not laid out yet; a resize will retry */
}
let disposed = false;
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
// Buffer keystrokes that arrive before the PTY finished opening.
let pending = "";
const onKey = term.onData((data) => {
if (handle) void handle.write(encoder.encode(data));
else pending += data;
});
opener(
{ cwd, rows: term.rows, cols: term.cols },
(bytes) => {
if (!disposed) term.write(bytes);
},
)
.then((h) => {
if (disposed) {
void h.close();
return;
}
handle = h;
if (pending) {
void h.write(encoder.encode(pending));
pending = "";
}
})
.catch((e: unknown) => {
if (!disposed) {
term.write(
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
);
}
});
// Refit + propagate size to the PTY on container resize.
const ro = new ResizeObserver(() => {
try {
fit.fit();
} catch {
return;
}
if (handle) void handle.resize(term.rows, term.cols);
});
ro.observe(container);
return () => {
disposed = true;
ro.disconnect();
onKey.dispose();
if (handle) void handle.close();
term.dispose();
};
// Only re-open on cwd change (or mount). The opener is read from a ref, and
// agent switches re-mount via `key`, so we must NOT depend on `open`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cwd]);
return (
<div
ref={containerRef}
data-testid="terminal-view"
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
/>
);
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -0,0 +1,3 @@
/** Terminals feature (L3): xterm.js wrapper bound to the `TerminalGateway`. */
export { TerminalView } from "./TerminalView";

256
frontend/src/ports/index.ts Normal file
View File

@ -0,0 +1,256 @@
/**
* UI ports (gateways) — interfaces describing *what the UI needs*, independent
* of transport (ARCHITECTURE §1.3). React components depend on these, never on
* `@tauri-apps/api` directly. Implemented by the Tauri adapters and by mocks.
*
* Signatures are intentionally minimal/skeletal for L1; later lots flesh out
* each gateway as their use cases land. They are aligned with the planned use
* cases (ARCHITECTURE §6) so the shape is stable.
*/
import type {
Agent,
AgentDrift,
AgentProfile,
DomainEvent,
FirstRunState,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
Project,
ProfileAvailability,
Template,
Unsubscribe,
} from "@/domain";
/** System-level gateway: health/ping + global domain-event subscription. */
export interface SystemGateway {
/** Calls the backend `health` command (smoke test of the whole pipeline). */
health(note?: string): Promise<HealthReport>;
/** Subscribes to relayed domain events. */
onDomainEvent(handler: (event: DomainEvent) => void): Promise<Unsubscribe>;
/**
* Opens a native folder picker and returns the chosen path, or `null` if the
* user cancelled. This is the only sanctioned way to pick a folder — all call
* sites go through this port; the Tauri plugin is only imported in the adapter.
*/
pickFolder(): Promise<string | null>;
}
/** Input for {@link AgentGateway.createAgent}. */
export interface CreateAgentInput {
name: string;
profileId: string;
initialContent?: string;
}
/** Agents: create, list, read/update context, delete, launch (L6). */
export interface AgentGateway {
/** Lists all agents belonging to the given project. */
listAgents(projectId: string): Promise<Agent[]>;
/** Creates a new agent from scratch; returns the created agent. */
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
/** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */
updateContext(projectId: string, agentId: string, content: string): Promise<void>;
/** Removes an agent from the project. */
deleteAgent(projectId: string, agentId: string): Promise<void>;
/**
* Launches the agent: opens a PTY, spawns the CLI, and wires the output stream.
* Mirrors {@link TerminalGateway.openTerminal} but invokes `launch_agent`.
*/
launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
}
/** Options for opening a terminal. */
export interface OpenTerminalOptions {
/** Working directory (typically the project root). */
cwd: string;
/** Initial terminal height in rows. */
rows: number;
/** Initial terminal width in columns. */
cols: number;
}
/**
* A live terminal handle returned by {@link TerminalGateway.openTerminal}.
*
* The output stream is delivered to the `onData` callback passed at open time
* (over a Tauri Channel in the real adapter); the handle exposes the input/
* control operations and a `close` that also tears the stream down.
*/
export interface TerminalHandle {
/** Stable session id (UUID) used by the backend for this PTY. */
readonly sessionId: string;
/** Sends bytes (xterm keystrokes) to the PTY. */
write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */
resize(rows: number, cols: number): Promise<void>;
/** Kills the PTY and stops the output stream. */
close(): Promise<void>;
}
/**
* Terminals (L3): open a PTY with a per-session output stream, then write/
* resize/close it through the returned {@link TerminalHandle}.
*/
export interface TerminalGateway {
/**
* Opens a terminal. `onData` receives every chunk of PTY output (bytes) as it
* arrives. Resolves once the PTY is spawned and the stream is wired.
*/
openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle>;
}
/** Projects: create/open/close/list (L2). */
export interface ProjectGateway {
/** Lists the projects known to the registry. */
listProjects(): Promise<Project[]>;
/** Creates a project from a root; returns the created project. */
createProject(name: string, root: string): Promise<Project>;
/** Opens a project by id; returns the opened project. */
openProject(projectId: string): Promise<Project>;
/** Closes a project by id. */
closeProject(projectId: string): Promise<void>;
}
/** Layout: load the terminal grid tree and apply mutating operations (L4). */
export interface LayoutGateway {
/** Loads a project's layout tree (defaults to a single cell if none persisted).
* When `layoutId` is omitted, the active layout for the project is used. */
loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree>;
/**
* Applies a split/merge/resize/move/setSession/setCellAgent operation; the backend persists
* `.ideai/layout.json` and returns the resulting tree.
* When `layoutId` is omitted, the active layout for the project is used.
*/
mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree>;
/** Lists all named layouts for a project, with the current active id. */
listLayouts(projectId: string): Promise<LayoutList>;
/** Creates a new named layout for a project; returns the new layout id. */
createLayout(projectId: string, name: string, kind?: LayoutKind): Promise<{ layoutId: string }>;
/** Renames a layout. */
renameLayout(projectId: string, layoutId: string, name: string): Promise<void>;
/** Deletes a layout; returns the new active layout id. */
deleteLayout(projectId: string, layoutId: string): Promise<{ activeId: string }>;
/** Sets the active layout for a project. */
setActiveLayout(projectId: string, layoutId: string): Promise<void>;
}
/** Git: status/commit/checkout/… (L8). */
export interface GitGateway {
status(projectId: string): Promise<GitFileStatus[]>;
stage(projectId: string, path: string): Promise<void>;
unstage(projectId: string, path: string): Promise<void>;
commit(projectId: string, message: string): Promise<GitCommit>;
branches(projectId: string): Promise<GitBranches>;
checkout(projectId: string, branch: string): Promise<void>;
log(projectId: string, limit: number): Promise<GitCommit[]>;
init(projectId: string): Promise<void>;
/** Returns the full commit DAG for the git-graph layout. */
graph(projectId: string, limit: number): Promise<GraphCommit[]>;
}
/** Remote (SSH/WSL) connection management (L9). */
export interface RemoteGateway {
connect(projectId: string): Promise<void>;
}
/** Input for {@link TemplateGateway.createTemplate}. */
export interface CreateTemplateInput {
name: string;
content: string;
defaultProfileId: string;
}
/**
* Templates (L7): CRUD for agent templates, creation of agents from templates,
* drift detection and synchronisation.
*/
export interface TemplateGateway {
/** Lists all templates. */
listTemplates(): Promise<Template[]>;
/** Creates a new template; returns the created template. */
createTemplate(input: CreateTemplateInput): Promise<Template>;
/** Updates a template's content; increments its version; returns the updated template. */
updateTemplate(templateId: string, content: string): Promise<Template>;
/** Deletes a template by id. */
deleteTemplate(templateId: string): Promise<void>;
/**
* Creates an agent in `projectId` based on the given template.
* The agent's origin will be `fromTemplate`; its context will be the template's `contentMd`.
*/
createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent>;
/** Returns the list of synchronized agents in `projectId` whose template has been updated. */
detectDrift(projectId: string): Promise<AgentDrift[]>;
/**
* Syncs a single agent to the current version of its template.
* Returns `{ synced: true, version }` on success, `{ synced: false, version: null }` for
* scratch / non-synchronized agents.
*/
syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }>;
}
/**
* AI profiles & first-run (L5). Drives the first-run wizard and profile
* management: the pre-filled reference catalogue, detection of installed CLIs,
* and CRUD/batch persistence of the chosen/edited/custom profiles.
*/
export interface ProfileGateway {
/** First-run state: whether to show the wizard + the reference catalogue. */
firstRunState(): Promise<FirstRunState>;
/** The pre-filled, editable reference catalogue (Claude/Codex/Gemini/Aider). */
referenceProfiles(): Promise<AgentProfile[]>;
/** Probes each candidate's detection command; returns availability (✓/✗). */
detectProfiles(candidates: AgentProfile[]): Promise<ProfileAvailability[]>;
/** Lists the configured profiles. */
listProfiles(): Promise<AgentProfile[]>;
/** Creates or replaces (by id) a single profile; returns the saved profile. */
saveProfile(profile: AgentProfile): Promise<AgentProfile>;
/** Deletes a profile by id. */
deleteProfile(profileId: string): Promise<void>;
/** Persists the batch of chosen profiles, closing the first run. */
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
}
/**
* The full set of gateways the app depends on, injected via the DI provider.
* The composition (real vs mock) is chosen in `app/`.
*/
export interface Gateways {
system: SystemGateway;
agent: AgentGateway;
terminal: TerminalGateway;
project: ProjectGateway;
layout: LayoutGateway;
git: GitGateway;
remote: RemoteGateway;
profile: ProfileGateway;
template: TemplateGateway;
}

View File

@ -0,0 +1 @@
# Shared UI kit, xterm.js wrapper, design system (ARCHITECTURE §10).

View File

@ -0,0 +1,33 @@
/**
* IdeA design system (LD) — public surface of the shared UI kit.
*
* Features import components and the `cn` helper from `@/shared`; the global
* theme (`styles/theme.css`) is imported once at the app entry point.
*/
export { cn } from "./lib/cn";
export type { ClassValue } from "./lib/cn";
export { Button } from "./ui/Button";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./ui/Button";
export { IconButton } from "./ui/IconButton";
export type { IconButtonProps, IconButtonSize } from "./ui/IconButton";
export { Input } from "./ui/Input";
export type { InputProps } from "./ui/Input";
export { Field } from "./ui/Field";
export type { FieldProps } from "./ui/Field";
export { Panel } from "./ui/Panel";
export type { PanelProps } from "./ui/Panel";
export { Tabs } from "./ui/Tabs";
export type { TabsProps, TabItem } from "./ui/Tabs";
export { Toolbar } from "./ui/Toolbar";
export type { ToolbarProps } from "./ui/Toolbar";
export { Spinner } from "./ui/Spinner";
export type { SpinnerProps } from "./ui/Spinner";

View File

@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { cn } from "./cn";
describe("cn", () => {
it("joins truthy fragments with single spaces", () => {
expect(cn("a", "b", "c")).toBe("a b c");
});
it("drops falsy fragments", () => {
expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
});
it("supports conditional fragments", () => {
const active = true;
const disabled = false;
expect(cn("base", active && "on", disabled && "off")).toBe("base on");
});
it("returns an empty string when everything is falsy", () => {
expect(cn(false, null, undefined)).toBe("");
});
});

View File

@ -0,0 +1,16 @@
/**
* `cn` — minimal className combiner for the design system.
*
* Joins truthy class fragments with a single space, dropping `false`,
* `null`, `undefined` and empty strings. Deliberately dependency-free (no
* `clsx`/`tailwind-merge`): the kit composes a small, controlled set of
* classes, and conditional fragments cover every call site.
*
* @example
* cn("px-2", isActive && "bg-primary", disabled && "opacity-50")
*/
export type ClassValue = string | false | null | undefined;
export function cn(...parts: ClassValue[]): string {
return parts.filter(Boolean).join(" ");
}

View File

@ -0,0 +1,76 @@
/*
* IdeA design system — global theme (LD).
*
* Tailwind v4, CSS-first: semantic tokens declared under `@theme` become
* utilities (e.g. `--color-surface` → `bg-surface text-surface border-surface`).
* The palette is a **dark IDE** theme by default — IdeA is an IDE, not a website.
* Features should consume these tokens + the `shared/ui` components rather than
* hard-coding colours.
*/
@import "tailwindcss";
@theme {
/* Surfaces (back-to-front) */
--color-canvas: #0e1116; /* app background */
--color-surface: #161b22; /* panels, cards */
--color-raised: #1c2430; /* inputs, hovered rows */
--color-overlay: #222c3a; /* popovers, menus */
/* Lines & text */
--color-border: #2b3440;
--color-border-strong: #3b4757;
--color-content: #e6edf3; /* primary text */
--color-muted: #9aa7b4; /* secondary text */
--color-faint: #6b7785; /* disabled / placeholder */
/* Accent & status */
--color-primary: #4c8dff;
--color-primary-hover: #3b7bf0;
--color-on-primary: #ffffff;
--color-danger: #f85149;
--color-success: #3fb950;
--color-warning: #d29922;
/* Typography */
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif;
--font-mono:
"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo,
Consolas, monospace;
/* Radii */
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
@layer base {
/* Tell the engine (incl. the Tauri WebKitGTK webview) to render native form
controls — <select> popups, <option>, checkboxes, scrollbars — in dark mode.
Without this, WebKitGTK paints them with the light system GTK theme, which is
why dropdowns looked white in the packaged app but not in a dark browser. */
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background-color: var(--color-canvas);
color: var(--color-content);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* A consistent focus ring across the kit (keyboard accessibility). */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}

View File

@ -0,0 +1,54 @@
/** Button — the primary action control of the design system (LD). */
import { forwardRef } from "react";
import { cn } from "../lib/cn";
import { Spinner } from "./Spinner";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual emphasis. Defaults to `secondary`. */
variant?: ButtonVariant;
/** Control height. Defaults to `md`. */
size?: ButtonSize;
/** When true, shows a spinner and disables the button. */
loading?: boolean;
}
const BASE =
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium " +
"transition-colors select-none disabled:opacity-50 disabled:cursor-not-allowed " +
"focus-visible:outline-none";
const SIZES: Record<ButtonSize, string> = {
sm: "h-7 px-2.5 text-xs",
md: "h-9 px-3.5 text-sm",
};
const VARIANTS: Record<ButtonVariant, string> = {
primary: "bg-primary text-on-primary hover:bg-primary-hover",
secondary: "bg-raised text-content border border-border hover:border-border-strong",
ghost: "text-muted hover:text-content hover:bg-raised",
danger: "bg-danger text-white hover:brightness-110",
};
/** A themeable button with variants, sizes and a loading state. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", loading = false, disabled, className, children, ...rest },
ref,
) {
return (
<button
ref={ref}
type={rest.type ?? "button"}
disabled={disabled || loading}
className={cn(BASE, SIZES[size], VARIANTS[variant], className)}
{...rest}
>
{loading && <Spinner size={size === "sm" ? 12 : 14} />}
{children}
</button>
);
});

View File

@ -0,0 +1,53 @@
/** Field — a labelled wrapper for form controls with hint/error text (LD). */
import { useId } from "react";
import { cn } from "../lib/cn";
export interface FieldProps {
/** Visible label text. */
label: string;
/** Optional helper text shown under the control. */
hint?: string;
/** Optional error message; when set, it replaces the hint and is announced. */
error?: string;
/** Visually hide the label (kept for screen readers). */
hideLabel?: boolean;
className?: string;
/**
* Render-prop receiving the generated `id` so the control can wire
* `id`/`aria-describedby` to the label and message.
*/
children: (props: { id: string; describedBy?: string }) => React.ReactNode;
}
/** Associates a label (and optional hint/error) with a single control. */
export function Field({ label, hint, error, hideLabel, className, children }: FieldProps) {
const id = useId();
const msgId = `${id}-msg`;
const message = error ?? hint;
return (
<div className={cn("flex flex-col gap-1", className)}>
<label
htmlFor={id}
className={cn(
"text-xs font-medium text-muted",
hideLabel && "sr-only",
)}
>
{label}
</label>
{children({ id, describedBy: message ? msgId : undefined })}
{message && (
<p
id={msgId}
role={error ? "alert" : undefined}
className={cn("text-xs", error ? "text-danger" : "text-faint")}
>
{message}
</p>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
/** IconButton — a square, icon-only button (close ×, toolbar actions) (LD). */
import { forwardRef } from "react";
import { cn } from "../lib/cn";
export type IconButtonSize = "sm" | "md";
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Required accessible name (icon-only buttons have no text). */
"aria-label": string;
/** Control size. Defaults to `md`. */
size?: IconButtonSize;
}
const SIZES: Record<IconButtonSize, string> = {
sm: "h-6 w-6 text-xs",
md: "h-8 w-8 text-sm",
};
/** A compact, icon-only button styled as a ghost control. */
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
{ size = "md", className, children, ...rest },
ref,
) {
return (
<button
ref={ref}
type={rest.type ?? "button"}
className={cn(
"inline-flex items-center justify-center rounded-md text-muted transition-colors",
"hover:bg-raised hover:text-content focus-visible:outline-none",
"disabled:opacity-50 disabled:cursor-not-allowed",
SIZES[size],
className,
)}
{...rest}
>
{children}
</button>
);
});

View File

@ -0,0 +1,31 @@
/** Input — a single-line text field (LD). */
import { forwardRef } from "react";
import { cn } from "../lib/cn";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
/** When true, paints the danger border (paired with a Field error). */
invalid?: boolean;
}
/** A themed text input; width is controlled by the parent (defaults to full). */
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ invalid = false, className, ...rest },
ref,
) {
return (
<input
ref={ref}
aria-invalid={invalid || undefined}
className={cn(
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
"border placeholder:text-faint outline-none transition-colors",
invalid ? "border-danger" : "border-border focus:border-primary",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
{...rest}
/>
);
});

View File

@ -0,0 +1,35 @@
/** Panel — a bordered surface/card, optionally with a header (LD). */
import { cn } from "../lib/cn";
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
/** Optional header title. */
title?: React.ReactNode;
/** Optional content rendered on the right of the header (actions). */
actions?: React.ReactNode;
/** Remove the default body padding (e.g. to host a flush list/terminal). */
flush?: boolean;
}
/** A raised surface used to group related content. */
export function Panel({ title, actions, flush, className, children, ...rest }: PanelProps) {
const hasHeader = title != null || actions != null;
return (
<section
className={cn("rounded-lg border border-border bg-surface", className)}
{...rest}
>
{hasHeader && (
<header className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
{typeof title === "string" ? (
<h3 className="text-sm font-semibold text-content">{title}</h3>
) : (
title
)}
{actions}
</header>
)}
<div className={cn(!flush && "p-4")}>{children}</div>
</section>
);
}

View File

@ -0,0 +1,27 @@
/** Spinner — an indeterminate loading indicator (LD). */
import { cn } from "../lib/cn";
export interface SpinnerProps {
/** Diameter in pixels. Defaults to 16. */
size?: number;
/** Extra classes (e.g. a colour override). */
className?: string;
/** Accessible label; defaults to "Loading". */
label?: string;
}
/** A CSS-only spinning ring that inherits the current text colour. */
export function Spinner({ size = 16, className, label = "Loading" }: SpinnerProps) {
return (
<span
role="status"
aria-label={label}
className={cn(
"inline-block animate-spin rounded-full border-2 border-current border-t-transparent",
className,
)}
style={{ width: size, height: size }}
/>
);
}

View File

@ -0,0 +1,76 @@
/** Tabs — a horizontal, optionally closable tab bar (LD).
*
* Presentation-only and controlled: the parent owns the active id and the open
* set. Used for the project tab bar (one tab per open project, ARCHITECTURE §10)
* but generic enough for any tabbed surface.
*/
import { cn } from "../lib/cn";
import { IconButton } from "./IconButton";
export interface TabItem {
/** Stable id, returned by `onSelect`/`onClose`. */
id: string;
/** Visible label. */
label: string;
}
export interface TabsProps {
/** Tabs to render, left to right. */
items: TabItem[];
/** Currently-active tab id. */
value: string | null;
/** Called with the id of the tab the user activates. */
onSelect: (id: string) => void;
/** When provided, each tab shows a close (×) control. */
onClose?: (id: string) => void;
className?: string;
}
/** A themed tab strip with selection and optional per-tab close. */
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
return (
<div
role="tablist"
className={cn("flex flex-wrap items-stretch gap-1", className)}
>
{items.map((tab) => {
const active = tab.id === value;
return (
<div
key={tab.id}
className={cn(
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
active
? "border-border-strong bg-raised"
: "border-transparent hover:bg-raised",
)}
>
<button
type="button"
role="tab"
aria-selected={active}
onClick={() => onSelect(tab.id)}
className={cn(
"px-2 py-1 text-sm focus-visible:outline-none",
active ? "font-semibold text-content" : "text-muted hover:text-content",
)}
>
{tab.label}
</button>
{onClose && (
<IconButton
size="sm"
aria-label={`close ${tab.label}`}
onClick={() => onClose(tab.id)}
className="opacity-60 group-hover:opacity-100"
>
×
</IconButton>
)}
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,27 @@
/** Toolbar — a thin horizontal action bar (LD). */
import { cn } from "../lib/cn";
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
/** Push trailing children to the right with an auto margin. */
justify?: "start" | "between" | "end";
}
const JUSTIFY = {
start: "justify-start",
between: "justify-between",
end: "justify-end",
} as const;
/** A flex row for grouping buttons/controls with consistent spacing. */
export function Toolbar({ justify = "start", className, children, ...rest }: ToolbarProps) {
return (
<div
role="toolbar"
className={cn("flex items-center gap-2", JUSTIFY[justify], className)}
{...rest}
>
{children}
</div>
);
}

View File

@ -0,0 +1,117 @@
/**
* LD — design-system component tests. Pure UI, rendered with React Testing
* Library; no backend, no gateways. We assert behaviour and accessibility
* (roles, names, aria-*) rather than exact Tailwind classes, so the visual
* design can evolve without breaking the suite.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
import { Input } from "./Input";
import { Field } from "./Field";
import { Tabs } from "./Tabs";
describe("Button", () => {
it("renders its label and fires onClick", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Save</Button>);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onClick).toHaveBeenCalledOnce();
});
it("is disabled and shows a spinner while loading", () => {
const onClick = vi.fn();
render(
<Button loading onClick={onClick}>
Save
</Button>,
);
const btn = screen.getByRole("button", { name: /Save/ }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(screen.getByRole("status")).toBeTruthy(); // spinner
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("defaults to type=button to avoid accidental form submits", () => {
render(<Button>Go</Button>);
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe(
"button",
);
});
});
describe("Input", () => {
it("flags aria-invalid when invalid", () => {
render(<Input aria-label="email" invalid />);
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBe("true");
});
it("is not aria-invalid by default", () => {
render(<Input aria-label="email" />);
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBeNull();
});
});
describe("Field", () => {
it("associates the label with the control via id", () => {
render(
<Field label="Project name">
{({ id, describedBy }) => (
<input id={id} aria-describedby={describedBy} />
)}
</Field>,
);
// getByLabelText resolves the label→control association.
expect(screen.getByLabelText("Project name")).toBeTruthy();
});
it("announces an error and wires aria-describedby", () => {
render(
<Field label="Root" error="must be absolute">
{({ id, describedBy }) => (
<input id={id} aria-describedby={describedBy} />
)}
</Field>,
);
const alert = screen.getByRole("alert");
expect(alert.textContent).toBe("must be absolute");
const input = screen.getByLabelText("Root");
expect(input.getAttribute("aria-describedby")).toBe(alert.id);
});
});
describe("Tabs", () => {
const items = [
{ id: "a", label: "alpha" },
{ id: "b", label: "beta" },
];
it("marks the active tab with aria-selected and reports selection", () => {
const onSelect = vi.fn();
render(<Tabs items={items} value="a" onSelect={onSelect} />);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(2);
const [alpha, beta] = tabs;
expect(alpha.getAttribute("aria-selected")).toBe("true");
expect(beta.getAttribute("aria-selected")).toBe("false");
fireEvent.click(beta);
expect(onSelect).toHaveBeenCalledWith("b");
});
it("renders a close control per tab only when onClose is given", () => {
const onClose = vi.fn();
const { rerender } = render(
<Tabs items={items} value="a" onSelect={() => {}} />,
);
expect(screen.queryByRole("button", { name: "close alpha" })).toBeNull();
rerender(<Tabs items={items} value="a" onSelect={() => {}} onClose={onClose} />);
fireEvent.click(screen.getByRole("button", { name: "close beta" }));
expect(onClose).toHaveBeenCalledWith("b");
});
});

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_USE_MOCK?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

23
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from "node:url";
// Vite config tuned for Tauri v2 (fixed dev port, no clearing the terminal).
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
},
build: {
target: "es2021",
outDir: "dist",
},
});

20
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,20 @@
/// <reference types="vitest" />
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
// Vitest config for the frontend hexagonal layers (domain/ports/adapters/app).
// jsdom is used so React-Testing-Library can render the DI provider.
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
environment: "jsdom",
globals: true,
include: ["src/**/*.test.{ts,tsx}"],
},
});