Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1200 lines
36 KiB
TypeScript
1200 lines
36 KiB
TypeScript
/**
|
|
* 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,
|
|
Skill,
|
|
SkillScope,
|
|
Template,
|
|
Unsubscribe,
|
|
} from "@/domain";
|
|
import type {
|
|
AgentGateway,
|
|
ConversationDetails,
|
|
CreateAgentInput,
|
|
CreateSkillInput,
|
|
CreateTemplateInput,
|
|
Gateways,
|
|
GitGateway,
|
|
LayoutGateway,
|
|
OpenTerminalOptions,
|
|
ProfileGateway,
|
|
ProjectGateway,
|
|
ReattachResult,
|
|
RemoteGateway,
|
|
SkillGateway,
|
|
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, "");
|
|
}
|
|
|
|
/**
|
|
* A live in-memory mock PTY session: it retains a scrollback (everything written
|
|
* to `onData`) and tracks the *current* output sink so a view can detach (sink
|
|
* cleared, session stays alive) and later re-attach (new sink, scrollback
|
|
* replayed). Only `close` ends the session — mirroring the backend's decoupling
|
|
* of PTY lifecycle from view lifecycle.
|
|
*/
|
|
class MockPtySession {
|
|
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
|
|
private scrollback: number[] = [];
|
|
/** Current view sink; `null` while detached. */
|
|
private sink: ((bytes: Uint8Array) => void) | null = null;
|
|
/** Whether the session was explicitly closed (PTY killed). */
|
|
closed = false;
|
|
|
|
constructor(
|
|
readonly sessionId: string,
|
|
sink: (bytes: Uint8Array) => void,
|
|
) {
|
|
this.sink = sink;
|
|
}
|
|
|
|
/** Records output into the scrollback and forwards it to the current sink. */
|
|
emit(bytes: Uint8Array): void {
|
|
if (this.closed) return;
|
|
for (const b of bytes) this.scrollback.push(b);
|
|
this.sink?.(bytes);
|
|
}
|
|
|
|
/** Detaches the current view: stop delivering, keep the session alive. */
|
|
detach(): void {
|
|
this.sink = null;
|
|
}
|
|
|
|
/** Re-attaches a new view, returning the retained scrollback to repaint. */
|
|
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
|
|
this.sink = sink;
|
|
return Uint8Array.from(this.scrollback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** Live agent PTY sessions, kept across detach so reattach can find them. */
|
|
private sessions = new Map<string, MockPtySession>();
|
|
|
|
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,
|
|
skills: [],
|
|
};
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Replaces an agent's assigned skills in-place.
|
|
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
|
|
* share the same in-memory store.
|
|
*/
|
|
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
|
|
const list = this.getAgents(projectId);
|
|
const idx = list.findIndex((a) => a.id === agentId);
|
|
if (idx === -1) return;
|
|
list[idx] = { ...list[idx], skills };
|
|
}
|
|
|
|
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();
|
|
const session = new MockPtySession(sessionId, onData);
|
|
this.sessions.set(sessionId, session);
|
|
// Greet so something is visible immediately (mirrors MockTerminalGateway).
|
|
queueMicrotask(() =>
|
|
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
|
|
);
|
|
const handle = makeMockHandle(session, () => this.sessions.delete(sessionId));
|
|
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
|
|
// newly-minted id surfaced on the handle so the caller persists it; a cell
|
|
// that already carries an id resumes it and nothing new is assigned.
|
|
if (!options.conversationId) {
|
|
return {
|
|
...handle,
|
|
assignedConversationId: `mock-conversation-${sessionId}`,
|
|
};
|
|
}
|
|
return handle;
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.closed) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent session ${sessionId} is not alive`,
|
|
};
|
|
throw err;
|
|
}
|
|
const scrollback = session.reattach(onData);
|
|
return {
|
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
|
scrollback,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Best-effort conversation details, keyed by conversation id (T7). Empty by
|
|
* default (degraded mode); a test seeds enriched details via
|
|
* {@link _setConversationDetails}.
|
|
*/
|
|
private conversationDetails = new Map<string, ConversationDetails>();
|
|
|
|
/** Seeds the (best-effort) details returned for a given conversation id. */
|
|
_setConversationDetails(
|
|
conversationId: string,
|
|
details: ConversationDetails,
|
|
): void {
|
|
this.conversationDetails.set(conversationId, details);
|
|
}
|
|
|
|
async inspectConversation(
|
|
_projectId: string,
|
|
_agentId: string,
|
|
conversationId: string,
|
|
): Promise<ConversationDetails> {
|
|
// Best-effort: an unknown conversation yields empty details (degraded mode),
|
|
// never an error — mirroring the backend contract.
|
|
return structuredClone(this.conversationDetails.get(conversationId) ?? {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
|
|
* (cooked-terminal CRLF translation) through the session so the scrollback
|
|
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
|
|
*/
|
|
function makeMockHandle(
|
|
session: MockPtySession,
|
|
unregister: () => void,
|
|
): TerminalHandle {
|
|
return {
|
|
sessionId: session.sessionId,
|
|
async write(data: Uint8Array): Promise<void> {
|
|
if (session.closed) return;
|
|
const out: number[] = [];
|
|
for (const b of data) {
|
|
if (b === 0x0d) out.push(0x0d, 0x0a);
|
|
else out.push(b);
|
|
}
|
|
session.emit(Uint8Array.from(out));
|
|
},
|
|
async resize(): Promise<void> {},
|
|
detach(): void {
|
|
session.detach();
|
|
},
|
|
async close(): Promise<void> {
|
|
session.closed = true;
|
|
unregister();
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
/** Live sessions kept across detach so reattach can find them. */
|
|
private sessions = new Map<string, MockPtySession>();
|
|
|
|
async openTerminal(
|
|
options: OpenTerminalOptions,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<TerminalHandle> {
|
|
this.seq += 1;
|
|
const sessionId = `mock-session-${this.seq}`;
|
|
const enc = new TextEncoder();
|
|
const session = new MockPtySession(sessionId, onData);
|
|
this.sessions.set(sessionId, session);
|
|
// Greet so something is visible immediately.
|
|
queueMicrotask(() =>
|
|
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
|
|
);
|
|
return makeMockHandle(session, () => this.sessions.delete(sessionId));
|
|
}
|
|
|
|
async reattach(
|
|
sessionId: string,
|
|
onData: (bytes: Uint8Array) => void,
|
|
): Promise<ReattachResult> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || session.closed) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `terminal session ${sessionId} is not alive`,
|
|
};
|
|
throw err;
|
|
}
|
|
const scrollback = session.reattach(onData);
|
|
return {
|
|
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
|
|
scrollback,
|
|
};
|
|
}
|
|
|
|
async closeTerminal(sessionId: string): Promise<void> {
|
|
// Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
|
|
// so a later reattach to it fails. No-op if the session is already gone.
|
|
const session = this.sessions.get(sessionId);
|
|
if (session) {
|
|
session.closed = true;
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
skills: [],
|
|
};
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stateful in-memory skill gateway (L12).
|
|
*
|
|
* Skills are keyed by `${scope}` (project scope is further partitioned by
|
|
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
|
|
* agent record held by the injected {@link MockAgentGateway}, so both gateways
|
|
* share one store — exactly like {@link MockTemplateGateway}.
|
|
*/
|
|
export class MockSkillGateway implements SkillGateway {
|
|
// Global skills are project-independent; project skills live under their id.
|
|
private global: Skill[] = [];
|
|
private byProject = new Map<string, Skill[]>();
|
|
private seq = 0;
|
|
|
|
constructor(private readonly agentGateway: MockAgentGateway) {}
|
|
|
|
private bucket(projectId: string, scope: SkillScope): Skill[] {
|
|
if (scope === "global") return this.global;
|
|
let list = this.byProject.get(projectId);
|
|
if (!list) {
|
|
list = [];
|
|
this.byProject.set(projectId, list);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
|
|
return structuredClone(this.bucket(projectId, scope));
|
|
}
|
|
|
|
async createSkill(input: CreateSkillInput): Promise<Skill> {
|
|
this.seq += 1;
|
|
const skill: Skill = {
|
|
id: `mock-skill-${this.seq}`,
|
|
name: input.name,
|
|
contentMd: input.content,
|
|
scope: input.scope,
|
|
};
|
|
this.bucket(input.projectId, input.scope).push(skill);
|
|
return structuredClone(skill);
|
|
}
|
|
|
|
async updateSkill(
|
|
projectId: string,
|
|
scope: SkillScope,
|
|
skillId: string,
|
|
content: string,
|
|
): Promise<Skill> {
|
|
const list = this.bucket(projectId, scope);
|
|
const idx = list.findIndex((s) => s.id === skillId);
|
|
if (idx === -1) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `skill ${skillId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
list[idx] = { ...list[idx], contentMd: content };
|
|
return structuredClone(list[idx]);
|
|
}
|
|
|
|
async deleteSkill(
|
|
projectId: string,
|
|
scope: SkillScope,
|
|
skillId: string,
|
|
): Promise<void> {
|
|
const list = this.bucket(projectId, scope);
|
|
const idx = list.findIndex((s) => s.id === skillId);
|
|
if (idx === -1) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `skill ${skillId} not found`,
|
|
};
|
|
throw err;
|
|
}
|
|
list.splice(idx, 1);
|
|
}
|
|
|
|
async assignSkill(
|
|
projectId: string,
|
|
agentId: string,
|
|
skillId: string,
|
|
scope: SkillScope,
|
|
): Promise<void> {
|
|
const agent = this.agentGateway
|
|
._rawAgents(projectId)
|
|
.find((a) => a.id === agentId);
|
|
if (!agent) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} not found in project ${projectId}`,
|
|
};
|
|
throw err;
|
|
}
|
|
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
|
|
this.agentGateway._setSkills(projectId, agentId, [
|
|
...agent.skills,
|
|
{ skillId, scope },
|
|
]);
|
|
}
|
|
|
|
async unassignSkill(
|
|
projectId: string,
|
|
agentId: string,
|
|
skillId: string,
|
|
): Promise<void> {
|
|
const agent = this.agentGateway
|
|
._rawAgents(projectId)
|
|
.find((a) => a.id === agentId);
|
|
if (!agent) {
|
|
const err: GatewayError = {
|
|
code: "NOT_FOUND",
|
|
message: `agent ${agentId} not found in project ${projectId}`,
|
|
};
|
|
throw err;
|
|
}
|
|
this.agentGateway._setSkills(
|
|
projectId,
|
|
agentId,
|
|
agent.skills.filter((s) => s.skillId !== skillId),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 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),
|
|
skill: new MockSkillGateway(agentGateway),
|
|
};
|
|
}
|
|
|
|
// Re-export GatewayError so tests can reference the thrown shape.
|
|
export type { GatewayError };
|