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:
373
frontend/src/features/agents/AgentsPanel.tsx
Normal file
373
frontend/src/features/agents/AgentsPanel.tsx
Normal file
@ -0,0 +1,373 @@
|
||||
/**
|
||||
* `AgentsPanel` — feature component for the agents panel (L6).
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useAgents}. Styled with
|
||||
* `@/shared` design system tokens; no inline styles, no classes invented outside
|
||||
* the theme.
|
||||
*
|
||||
* When the user clicks Launch, an agent terminal is mounted below the agent
|
||||
* list using a {@link TerminalView} with a custom `open` prop that delegates
|
||||
* to `agent.launchAgent`. A Stop button unmounts it.
|
||||
*
|
||||
* Feature #3: the create form includes a template selector. Choosing a template
|
||||
* calls `createAgentFromTemplate`; leaving it at "(none / from scratch)" uses
|
||||
* the existing `createAgent` path with the selected profile.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||||
import { useDrift } from "@/features/templates/useDrift";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { useAgents } from "./useAgents";
|
||||
|
||||
export interface AgentsPanelProps {
|
||||
/** The project whose agents to manage. */
|
||||
projectId: string;
|
||||
/**
|
||||
* The filesystem root of the project. Passed as the `cwd` to the agent
|
||||
* terminal. Supplied by `ProjectsView` which has `active.root`.
|
||||
*/
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
||||
const vm = useAgents(projectId);
|
||||
const drift = useDrift(projectId);
|
||||
const gateways = useGateways();
|
||||
const templateGw = gateways.template ?? null;
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newProfileId, setNewProfileId] = useState("");
|
||||
const [newTemplateId, setNewTemplateId] = useState("");
|
||||
|
||||
// Templates available for the selector (loaded lazily on first render via a
|
||||
// separate effect handled inline via hook state)
|
||||
const [templates, setTemplates] = useState<import("@/domain").Template[]>([]);
|
||||
const [templatesLoaded, setTemplatesLoaded] = useState(false);
|
||||
|
||||
// Load templates once on mount (best-effort — if it fails the selector just stays empty)
|
||||
if (!templatesLoaded && templateGw) {
|
||||
setTemplatesLoaded(true);
|
||||
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
||||
}
|
||||
|
||||
// Context editor state — local copy before Save
|
||||
const [editedContext, setEditedContext] = useState(vm.context);
|
||||
|
||||
// When the hook loads the context for a newly selected agent, mirror it.
|
||||
// We use a derived check: if the hook's context changed externally we reset.
|
||||
const [lastLoadedContext, setLastLoadedContext] = useState(vm.context);
|
||||
if (vm.context !== lastLoadedContext) {
|
||||
setLastLoadedContext(vm.context);
|
||||
setEditedContext(vm.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the agent currently being launched / running in the terminal pane.
|
||||
* Kept in local state so the terminal container can appear immediately when
|
||||
* the user clicks Launch (before the gateway resolves). vm.runningAgentId is
|
||||
* set by the hook once launchAgent resolves.
|
||||
*/
|
||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||
|
||||
const canCreate = newName.trim().length > 0 && !vm.busy;
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canCreate) return;
|
||||
if (newTemplateId && templateGw) {
|
||||
// Create from template — the template imposes its default profile.
|
||||
await templateGw.createAgentFromTemplate(projectId, newTemplateId, {
|
||||
name: newName.trim(),
|
||||
synchronized: true,
|
||||
});
|
||||
// Refresh the agents list since we bypassed the hook's createAgent path.
|
||||
await vm.refresh();
|
||||
} else {
|
||||
const profileId = newProfileId.trim() || "";
|
||||
await vm.createAgent(newName.trim(), profileId);
|
||||
}
|
||||
setNewName("");
|
||||
setNewProfileId("");
|
||||
setNewTemplateId("");
|
||||
}
|
||||
|
||||
function handleLaunch(agentId: string) {
|
||||
setActiveAgentId(agentId);
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
vm.stopAgent();
|
||||
setActiveAgentId(null);
|
||||
}
|
||||
|
||||
const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null;
|
||||
|
||||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||||
const hasTemplate = newTemplateId !== "";
|
||||
|
||||
return (
|
||||
<Panel title="Agents" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Create form ── */}
|
||||
<form
|
||||
onSubmit={(e) => void handleCreate(e)}
|
||||
className="flex flex-wrap items-end gap-2 border-b border-border p-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-name-input"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="agent-name-input"
|
||||
aria-label="agent name"
|
||||
placeholder="My agent"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="min-w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template selector */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-template-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
id="agent-template-select"
|
||||
aria-label="agent template"
|
||||
value={newTemplateId}
|
||||
onChange={(e) => setNewTemplateId(e.target.value)}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
disabled={vm.busy}
|
||||
>
|
||||
<option value="">(none / from scratch)</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Profile selector — hidden when a template is chosen */}
|
||||
{!hasTemplate && (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<label
|
||||
htmlFor="agent-profile-select"
|
||||
className="text-xs font-medium text-muted"
|
||||
>
|
||||
Profile
|
||||
{vm.profiles.length === 0 && (
|
||||
<span className="ml-1 text-faint">(no profiles configured — you can still enter an id)</span>
|
||||
)}
|
||||
</label>
|
||||
{vm.profiles.length > 0 ? (
|
||||
<select
|
||||
id="agent-profile-select"
|
||||
aria-label="agent profile"
|
||||
value={newProfileId}
|
||||
onChange={(e) => setNewProfileId(e.target.value)}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<option value="">— select profile —</option>
|
||||
{vm.profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
id="agent-profile-select"
|
||||
aria-label="agent profile"
|
||||
placeholder="profile-id (optional)"
|
||||
value={newProfileId}
|
||||
onChange={(e) => setNewProfileId(e.target.value)}
|
||||
className="min-w-32"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
aria-label="create agent"
|
||||
disabled={!canCreate}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* ── Agent list ── */}
|
||||
<div className="p-4">
|
||||
{vm.agents.length === 0 ? (
|
||||
<p className="text-sm text-muted">No agents yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.agents.map((a) => {
|
||||
const isSelected = a.id === vm.selectedAgentId;
|
||||
const isRunning = a.id === activeAgentId;
|
||||
const profileName =
|
||||
vm.profiles.find((p) => p.id === a.profileId)?.name ??
|
||||
a.profileId;
|
||||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0",
|
||||
isSelected && "rounded-md bg-raised px-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void vm.selectAgent(a.id)}
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left"
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-medium text-content">{a.name}</span>
|
||||
{agentDrift && (
|
||||
<span
|
||||
aria-label="update available"
|
||||
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
update available
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted">{profileName}</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{agentDrift && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label={`sync ${a.name}`}
|
||||
disabled={vm.busy || drift.driftBusy}
|
||||
onClick={() =>
|
||||
void drift.syncAgent(a.id, vm.refresh)
|
||||
}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
)}
|
||||
{isRunning ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`stop ${a.name}`}
|
||||
onClick={handleStop}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
aria-label={`launch ${a.name}`}
|
||||
disabled={vm.busy || activeAgentId !== null}
|
||||
onClick={() => handleLaunch(a.id)}
|
||||
>
|
||||
Launch
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`delete ${a.name}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.deleteAgent(a.id)}
|
||||
className="text-danger hover:text-danger"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Agent terminal ── */}
|
||||
{activeAgentId !== null && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="h-96 overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<TerminalView
|
||||
cwd={projectRoot}
|
||||
open={(opts, onData) =>
|
||||
vm.launchAgent(activeAgentId, opts, onData)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Context editor ── */}
|
||||
{selectedAgent && (
|
||||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-content">
|
||||
Context — {selectedAgent.name}
|
||||
</h4>
|
||||
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="agent context"
|
||||
value={editedContext}
|
||||
onChange={(e) => setEditedContext(e.target.value)}
|
||||
rows={10}
|
||||
className={cn(
|
||||
"w-full rounded-md bg-raised px-3 py-2 text-sm text-content",
|
||||
"border border-border outline-none transition-colors",
|
||||
"focus:border-primary placeholder:text-faint",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 resize-y font-mono",
|
||||
)}
|
||||
disabled={vm.busy}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{vm.busy && <Spinner size={14} />}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={vm.busy}
|
||||
onClick={() => void vm.saveContext(editedContext)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
386
frontend/src/features/agents/agents.test.tsx
Normal file
386
frontend/src/features/agents/agents.test.tsx
Normal file
@ -0,0 +1,386 @@
|
||||
/**
|
||||
* L6 — agents feature wired to the stateful `MockAgentGateway` (and a seeded
|
||||
* `MockProfileGateway`) via the real `DIProvider`.
|
||||
*
|
||||
* Covers:
|
||||
* - create → agent appears in list
|
||||
* - select → context is shown; edit + save → re-reading shows new content
|
||||
* - delete → agent removed from list
|
||||
* - launch → `launchAgent` is called and mounts a terminal view; stop unmounts it
|
||||
* - empty name → Create button disabled
|
||||
* - MockAgentGateway unit behaviour: slug deduplication, NOT_FOUND errors
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
|
||||
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { AgentsPanel } from "./AgentsPanel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_ID = "proj-test-001";
|
||||
|
||||
/**
|
||||
* Renders `AgentsPanel` behind a `DIProvider` with an isolated
|
||||
* `MockAgentGateway` (and optionally a seeded `MockProfileGateway`).
|
||||
*/
|
||||
function renderPanel(
|
||||
agent: MockAgentGateway = new MockAgentGateway(),
|
||||
profile: MockProfileGateway = new MockProfileGateway(),
|
||||
projectRoot = "/home/me/proj",
|
||||
template?: MockTemplateGateway,
|
||||
) {
|
||||
const tmpl = template ?? new MockTemplateGateway(agent);
|
||||
const gateways = { agent, profile, template: tmpl } as unknown as Gateways;
|
||||
return {
|
||||
agent,
|
||||
profile,
|
||||
template: tmpl,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AgentsPanel projectId={PROJECT_ID} projectRoot={projectRoot} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Waits until no Spinner is visible (busy → idle). */
|
||||
async function waitForIdle() {
|
||||
// The Create button is enabled when not busy and name is non-empty.
|
||||
// We wait for the panel to stabilise by waiting for "No agents yet." or
|
||||
// for the agent list to be rendered.
|
||||
await waitFor(() => {
|
||||
// Panel has finished its initial load when the form is accessible.
|
||||
expect(screen.getByLabelText("agent name")).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
/** Types a name into the agent name field, optionally selects a profile, and clicks Create. */
|
||||
async function createAgent(name: string, profileId?: string) {
|
||||
await waitForIdle();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||
target: { value: name },
|
||||
});
|
||||
|
||||
if (profileId) {
|
||||
const profileInput = screen.queryByLabelText("agent profile");
|
||||
if (profileInput) {
|
||||
fireEvent.change(profileInput, { target: { value: profileId } });
|
||||
}
|
||||
}
|
||||
|
||||
const createBtn = screen.getByRole("button", { name: "create agent" });
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(createBtn);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AgentsPanel (with MockAgentGateway)", () => {
|
||||
it("shows 'No agents yet.' when the project has no agents", async () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
expect(screen.getByText("No agents yet.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating an agent adds it to the list", async () => {
|
||||
renderPanel();
|
||||
await createAgent("My Worker");
|
||||
const agent = await screen.findByText("My Worker");
|
||||
expect(agent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("the Create button is disabled when the name is empty", async () => {
|
||||
renderPanel();
|
||||
await waitForIdle();
|
||||
// Name field is empty by default → disabled
|
||||
const btn = screen.getByRole("button", { name: "create agent" });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("selecting an agent displays its context", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
// Pre-seed an agent with initial content.
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
name: "Alpha",
|
||||
profileId: "p1",
|
||||
initialContent: "## Alpha context",
|
||||
});
|
||||
renderPanel(agent);
|
||||
|
||||
await waitForIdle();
|
||||
|
||||
// Click on the agent row button (aria-pressed) to select it — not the
|
||||
// "launch Alpha" action button.
|
||||
const buttons = screen.getAllByRole("button", { name: /alpha/i });
|
||||
const rowBtn = buttons.find(
|
||||
(b) => b.hasAttribute("aria-pressed"),
|
||||
)!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
const textarea = await screen.findByLabelText("agent context");
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("## Alpha context");
|
||||
});
|
||||
|
||||
it("editing and saving context persists the new content", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
name: "Beta",
|
||||
profileId: "p1",
|
||||
initialContent: "initial",
|
||||
});
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
// Select the agent row button (aria-pressed), not the action buttons.
|
||||
const buttons = screen.getAllByRole("button", { name: /beta/i });
|
||||
const rowBtn = buttons.find((b) => b.hasAttribute("aria-pressed"))!;
|
||||
fireEvent.click(rowBtn);
|
||||
|
||||
const textarea = await screen.findByLabelText("agent context");
|
||||
fireEvent.change(textarea, { target: { value: "updated content" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// After save the gateway should hold the new content.
|
||||
await waitFor(async () => {
|
||||
const agents = await agent.listAgents(PROJECT_ID);
|
||||
const ctx = await agent.readContext(PROJECT_ID, agents[0].id);
|
||||
expect(ctx).toBe("updated content");
|
||||
});
|
||||
});
|
||||
|
||||
it("deleting an agent removes it from the list", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, {
|
||||
name: "Gamma",
|
||||
profileId: "p1",
|
||||
});
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
await screen.findByText("Gamma");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "delete Gamma" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Gamma")).toBeNull();
|
||||
});
|
||||
|
||||
// Gateway-level check.
|
||||
const remaining = await agent.listAgents(PROJECT_ID);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("launching an agent calls launchAgent and mounts the terminal view", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const createdAgent = await agent.createAgent(PROJECT_ID, {
|
||||
name: "Delta",
|
||||
profileId: "p1",
|
||||
});
|
||||
|
||||
// Spy on launchAgent.
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "launch Delta" }));
|
||||
|
||||
// The terminal container div (data-testid="terminal-view") should be mounted.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// launchAgent should have been called with the correct projectId and agentId.
|
||||
await waitFor(() => {
|
||||
if (launchSpy.mock.calls.length > 0) {
|
||||
expect(launchSpy.mock.calls[0][0]).toBe(PROJECT_ID);
|
||||
expect(launchSpy.mock.calls[0][1]).toBe(createdAgent.id);
|
||||
// options (rows/cols) and onData callback
|
||||
expect(typeof launchSpy.mock.calls[0][2]).toBe("object");
|
||||
expect(typeof launchSpy.mock.calls[0][3]).toBe("function");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("stopping an agent unmounts the terminal view", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
await agent.createAgent(PROJECT_ID, { name: "Echo", profileId: "p1" });
|
||||
|
||||
renderPanel(agent);
|
||||
await waitForIdle();
|
||||
|
||||
// Launch
|
||||
fireEvent.click(screen.getByRole("button", { name: "launch Echo" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Stop
|
||||
fireEvent.click(screen.getByRole("button", { name: "stop Echo" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("terminal-view")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting a template calls createAgentFromTemplate instead of createAgent", async () => {
|
||||
const agent = new MockAgentGateway();
|
||||
const tmpl = new MockTemplateGateway(agent);
|
||||
// Seed a template so the selector has something to choose
|
||||
const t = await tmpl.createTemplate({
|
||||
name: "My Template",
|
||||
content: "# ctx",
|
||||
defaultProfileId: "p1",
|
||||
});
|
||||
|
||||
const createFromTemplateSpy = vi.spyOn(tmpl, "createAgentFromTemplate");
|
||||
|
||||
renderPanel(agent, new MockProfileGateway(), "/home/me/proj", tmpl);
|
||||
await waitForIdle();
|
||||
|
||||
// Select the template in the dropdown
|
||||
const templateSelect = screen.getByLabelText("agent template") as HTMLSelectElement;
|
||||
fireEvent.change(templateSelect, { target: { value: t.id } });
|
||||
|
||||
// Fill agent name
|
||||
fireEvent.change(screen.getByLabelText("agent name"), {
|
||||
target: { value: "From Template" },
|
||||
});
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "create agent" }));
|
||||
|
||||
// createAgentFromTemplate should have been called with the correct args
|
||||
await waitFor(() => {
|
||||
expect(createFromTemplateSpy).toHaveBeenCalledWith(
|
||||
PROJECT_ID,
|
||||
t.id,
|
||||
{ name: "From Template", synchronized: true },
|
||||
);
|
||||
});
|
||||
|
||||
// Agent should appear in the list
|
||||
await screen.findByText("From Template");
|
||||
});
|
||||
|
||||
it("shows profiles in the dropdown when profiles are configured", async () => {
|
||||
const profile = new MockProfileGateway();
|
||||
await profile.configureProfiles([
|
||||
{
|
||||
id: "prof-1",
|
||||
name: "Claude Code",
|
||||
command: "claude",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
},
|
||||
]);
|
||||
renderPanel(new MockAgentGateway(), profile);
|
||||
await waitForIdle();
|
||||
|
||||
// The select element should contain the profile option.
|
||||
const select = screen.getByLabelText("agent profile") as HTMLSelectElement;
|
||||
const options = Array.from(select.options).map((o) => o.text);
|
||||
expect(options).toContain("Claude Code");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockAgentGateway unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Helper: minimal valid options for launchAgent. */
|
||||
const LAUNCH_OPTS = { cwd: "/tmp", rows: 24, cols: 80 };
|
||||
const NOOP_ON_DATA = (_bytes: Uint8Array) => {};
|
||||
|
||||
describe("MockAgentGateway (unit)", () => {
|
||||
it("creates agents with unique slugified contextPaths", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const a1 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
|
||||
const a2 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
|
||||
const a3 = await gw.createAgent("proj", { name: "My Agent", profileId: "p" });
|
||||
|
||||
expect(a1.contextPath).toBe("agents/my-agent.md");
|
||||
expect(a2.contextPath).toBe("agents/my-agent-2.md");
|
||||
expect(a3.contextPath).toBe("agents/my-agent-3.md");
|
||||
});
|
||||
|
||||
it("readContext throws NOT_FOUND for unknown agent", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
await expect(gw.readContext("proj", "ghost-id")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("deleteAgent throws NOT_FOUND for unknown agent", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
await expect(gw.deleteAgent("proj", "ghost-id")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("launchAgent returns a TerminalHandle with sequential session ids", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const a1 = await gw.createAgent("proj", { name: "A1", profileId: "p" });
|
||||
const a2 = await gw.createAgent("proj", { name: "A2", profileId: "p" });
|
||||
const h1 = await gw.launchAgent("proj", a1.id, LAUNCH_OPTS, NOOP_ON_DATA);
|
||||
const h2 = await gw.launchAgent("proj", a2.id, LAUNCH_OPTS, NOOP_ON_DATA);
|
||||
expect(h1.sessionId).toBe("mock-agent-session-1");
|
||||
expect(h2.sessionId).toBe("mock-agent-session-2");
|
||||
});
|
||||
|
||||
it("launchAgent throws NOT_FOUND for an agent not in the project", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
await expect(
|
||||
gw.launchAgent("proj", "unknown-id", LAUNCH_OPTS, NOOP_ON_DATA),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("launchAgent greeting is delivered via onData", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const a = await gw.createAgent("proj", { name: "Greeter", profileId: "p" });
|
||||
const received: Uint8Array[] = [];
|
||||
await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) => received.push(bytes));
|
||||
// Wait for the queueMicrotask greeting
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const dec = new TextDecoder();
|
||||
const msg = received.map((b) => dec.decode(b)).join("");
|
||||
expect(msg).toContain(`agent ${a.id}`);
|
||||
});
|
||||
|
||||
it("launchAgent handle write echoes bytes back via onData", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const a = await gw.createAgent("proj", { name: "Echo", profileId: "p" });
|
||||
const received: Uint8Array[] = [];
|
||||
const handle = await gw.launchAgent("proj", a.id, LAUNCH_OPTS, (bytes) =>
|
||||
received.push(bytes),
|
||||
);
|
||||
await handle.write(new TextEncoder().encode("hi"));
|
||||
const dec = new TextDecoder();
|
||||
const echoed = received.map((b) => dec.decode(b)).join("");
|
||||
// The greeting (via queueMicrotask) and the echo both arrive; just check echo is there
|
||||
expect(echoed).toContain("hi");
|
||||
});
|
||||
|
||||
it("projects are isolated — agents in one project don't appear in another", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
await gw.createAgent("proj-A", { name: "A Agent", profileId: "p" });
|
||||
const listB = await gw.listAgents("proj-B");
|
||||
expect(listB).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
8
frontend/src/features/agents/index.ts
Normal file
8
frontend/src/features/agents/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Agents feature — public surface (L6).
|
||||
*/
|
||||
|
||||
export { AgentsPanel } from "./AgentsPanel";
|
||||
export type { AgentsPanelProps } from "./AgentsPanel";
|
||||
export { useAgents } from "./useAgents";
|
||||
export type { AgentsViewModel } from "./useAgents";
|
||||
211
frontend/src/features/agents/useAgents.ts
Normal file
211
frontend/src/features/agents/useAgents.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* `useAgents` — view-model hook for the agents feature (L6).
|
||||
*
|
||||
* Owns the agents feature state for a given project and exposes the actions the
|
||||
* UI triggers. Consumes {@link AgentGateway} and {@link ProfileGateway}
|
||||
* exclusively; never touches `invoke()` or `@tauri-apps/api`, keeping the
|
||||
* component layer testable with mock gateways (ARCHITECTURE §1.3).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { Agent, AgentProfile, GatewayError } from "@/domain";
|
||||
import type { OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the agents UI needs from this hook. */
|
||||
export interface AgentsViewModel {
|
||||
/** All agents known to the project. */
|
||||
agents: Agent[];
|
||||
/** Id of the currently selected agent, or `null`. */
|
||||
selectedAgentId: string | null;
|
||||
/** The `.md` context of the selected agent (loaded on select). */
|
||||
context: string;
|
||||
/** Profiles available for assigning to a new agent. */
|
||||
profiles: AgentProfile[];
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/**
|
||||
* Id of the agent whose terminal is currently running, or `null`.
|
||||
* Set when the user clicks Launch; cleared when the terminal is stopped.
|
||||
*/
|
||||
runningAgentId: string | null;
|
||||
/** Reloads the agent list. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Creates a new agent and refreshes the list. */
|
||||
createAgent: (name: string, profileId: string, initialContent?: string) => Promise<void>;
|
||||
/** Selects an agent and loads its context. */
|
||||
selectAgent: (agentId: string) => Promise<void>;
|
||||
/** Saves the context for the currently selected agent. */
|
||||
saveContext: (content: string) => Promise<void>;
|
||||
/** Deletes an agent; deselects if it was selected. */
|
||||
deleteAgent: (agentId: string) => Promise<void>;
|
||||
/**
|
||||
* Returns an opener compatible with `TerminalView`'s `open` prop for the
|
||||
* given agent. Calling it sets `runningAgentId`. The component unmounting
|
||||
* will call `handle.close()` automatically via the TerminalView cleanup.
|
||||
*/
|
||||
launchAgent: (
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
) => Promise<TerminalHandle>;
|
||||
/** Clears `runningAgentId` (called by the Stop button to unmount the terminal). */
|
||||
stopAgent: () => void;
|
||||
}
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export function useAgents(projectId: string): AgentsViewModel {
|
||||
const { agent, profile } = useGateways();
|
||||
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [context, setContext] = useState<string>("");
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [runningAgentId, setRunningAgentId] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [agentList, profileList] = await Promise.all([
|
||||
agent.listAgents(projectId),
|
||||
profile.listProfiles(),
|
||||
]);
|
||||
setAgents(agentList);
|
||||
setProfiles(profileList);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [agent, profile, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const createAgent = useCallback(
|
||||
async (name: string, profileId: string, initialContent?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await agent.createAgent(projectId, {
|
||||
name,
|
||||
profileId,
|
||||
initialContent,
|
||||
});
|
||||
setAgents((prev) => [...prev, created]);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const selectAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ctx = await agent.readContext(projectId, agentId);
|
||||
setSelectedAgentId(agentId);
|
||||
setContext(ctx);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const saveContext = useCallback(
|
||||
async (content: string) => {
|
||||
if (!selectedAgentId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await agent.updateContext(projectId, selectedAgentId, content);
|
||||
setContext(content);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId, selectedAgentId],
|
||||
);
|
||||
|
||||
const deleteAgent = useCallback(
|
||||
async (agentId: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await agent.deleteAgent(projectId, agentId);
|
||||
setAgents((prev) => prev.filter((a) => a.id !== agentId));
|
||||
setSelectedAgentId((current) => (current === agentId ? null : current));
|
||||
if (selectedAgentId === agentId) setContext("");
|
||||
// Also stop the terminal if the running agent was just deleted.
|
||||
setRunningAgentId((current) => (current === agentId ? null : current));
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[agent, projectId, selectedAgentId],
|
||||
);
|
||||
|
||||
const launchAgent = useCallback(
|
||||
async (
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
): Promise<TerminalHandle> => {
|
||||
setError(null);
|
||||
try {
|
||||
const handle = await agent.launchAgent(projectId, agentId, options, onData);
|
||||
setRunningAgentId(agentId);
|
||||
return handle;
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[agent, projectId],
|
||||
);
|
||||
|
||||
const stopAgent = useCallback(() => {
|
||||
setRunningAgentId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
agents,
|
||||
selectedAgentId,
|
||||
context,
|
||||
profiles,
|
||||
error,
|
||||
busy,
|
||||
runningAgentId,
|
||||
refresh,
|
||||
createAgent,
|
||||
selectAgent,
|
||||
saveContext,
|
||||
deleteAgent,
|
||||
launchAgent,
|
||||
stopAgent,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user