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:
236
frontend/src/features/templates/TemplateEditor.tsx
Normal file
236
frontend/src/features/templates/TemplateEditor.tsx
Normal 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 ..."
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
179
frontend/src/features/templates/TemplatesPanel.tsx
Normal file
179
frontend/src/features/templates/TemplatesPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
frontend/src/features/templates/index.ts
Normal file
10
frontend/src/features/templates/index.ts
Normal 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";
|
||||
503
frontend/src/features/templates/templates.test.tsx
Normal file
503
frontend/src/features/templates/templates.test.tsx
Normal 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" });
|
||||
});
|
||||
});
|
||||
91
frontend/src/features/templates/useDrift.ts
Normal file
91
frontend/src/features/templates/useDrift.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
149
frontend/src/features/templates/useTemplates.ts
Normal file
149
frontend/src/features/templates/useTemplates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user