feat: add main features

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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