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,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>
);
}