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:
62
frontend/src/adapters/agent.test.ts
Normal file
62
frontend/src/adapters/agent.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
108
frontend/src/adapters/agent.ts
Normal file
108
frontend/src/adapters/agent.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
53
frontend/src/adapters/git.ts
Normal file
53
frontend/src/adapters/git.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
frontend/src/adapters/index.ts
Normal file
62
frontend/src/adapters/index.ts
Normal 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 (L2–L9) 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,
|
||||
};
|
||||
56
frontend/src/adapters/layout.ts
Normal file
56
frontend/src/adapters/layout.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
925
frontend/src/adapters/mock/index.ts
Normal file
925
frontend/src/adapters/mock/index.ts
Normal 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 };
|
||||
164
frontend/src/adapters/mock/layout.test.ts
Normal file
164
frontend/src/adapters/mock/layout.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
89
frontend/src/adapters/mock/mock.test.ts
Normal file
89
frontend/src/adapters/mock/mock.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
94
frontend/src/adapters/mock/profile.test.ts
Normal file
94
frontend/src/adapters/mock/profile.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
114
frontend/src/adapters/mock/terminal.test.ts
Normal file
114
frontend/src/adapters/mock/terminal.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
frontend/src/adapters/profile.ts
Normal file
46
frontend/src/adapters/profile.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
30
frontend/src/adapters/project.ts
Normal file
30
frontend/src/adapters/project.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
40
frontend/src/adapters/system.ts
Normal file
40
frontend/src/adapters/system.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
71
frontend/src/adapters/template.ts
Normal file
71
frontend/src/adapters/template.ts
Normal 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 } },
|
||||
);
|
||||
}
|
||||
}
|
||||
65
frontend/src/adapters/terminal.ts
Normal file
65
frontend/src/adapters/terminal.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user