933 lines
37 KiB
TypeScript
933 lines
37 KiB
TypeScript
/**
|
||
* `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 { useEffect, useState } from "react";
|
||
|
||
import { Button, Input, Panel, SmallDropdown, Spinner, cn } from "@/shared";
|
||
import { useI18n } from "@/i18n";
|
||
import { TerminalView } from "@/features/terminals/TerminalView";
|
||
import { AnnouncementsPreview } from "@/features/announcements";
|
||
import { useDrift } from "@/features/templates/useDrift";
|
||
import { templateProfileLabel } from "@/features/templates/templatePresentation";
|
||
import { useGateways } from "@/app/di";
|
||
import { useAgents } from "./useAgents";
|
||
import { publishAgentRosterChanged } from "./agentRosterEvents";
|
||
import { correlateModelServerStatus } from "./modelServerLaunch";
|
||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||
import type {
|
||
Agent,
|
||
AgentProfile,
|
||
EffortOption,
|
||
EffortSelection,
|
||
ResolvedAgentSystemPermissions,
|
||
} from "@/domain";
|
||
|
||
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 { tr } = useI18n();
|
||
const vm = useAgents(projectId);
|
||
const drift = useDrift(projectId);
|
||
const gateways = useGateways();
|
||
const templateGw = gateways.template ?? null;
|
||
const permissionGw = gateways.permission ?? 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 [stopStateByAgent, setStopStateByAgent] = useState<
|
||
Record<string, "stopping" | "incomplete">
|
||
>({});
|
||
const [panelNodeIds, setPanelNodeIds] = useState<Record<string, string>>({});
|
||
|
||
/**
|
||
* Live PTY session id of the running agent terminal, keyed by agent id. Lets
|
||
* the terminal re-attach (rather than re-launch) when the panel re-mounts the
|
||
* view, so navigating away never kills the agent.
|
||
*/
|
||
const [agentSessions, setAgentSessions] = useState<Record<string, string>>(
|
||
{},
|
||
);
|
||
const [networkByAgent, setNetworkByAgent] = useState<
|
||
Record<string, ResolvedAgentSystemPermissions>
|
||
>({});
|
||
|
||
useEffect(() => {
|
||
if (!permissionGw || vm.agents.length === 0) {
|
||
setNetworkByAgent({});
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void Promise.all(
|
||
vm.agents.map(async (candidate) => {
|
||
try {
|
||
return [
|
||
candidate.id,
|
||
await permissionGw.resolveAgentSystemPermissions(
|
||
projectId,
|
||
candidate.id,
|
||
),
|
||
] as const;
|
||
} catch {
|
||
return [candidate.id, null] as const;
|
||
}
|
||
}),
|
||
).then((pairs) => {
|
||
if (cancelled) return;
|
||
setNetworkByAgent(
|
||
Object.fromEntries(
|
||
pairs.filter(
|
||
(pair): pair is readonly [string, ResolvedAgentSystemPermissions] =>
|
||
pair[1] !== null,
|
||
),
|
||
),
|
||
);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [permissionGw, projectId, vm.agents]);
|
||
|
||
/**
|
||
* Pending profile change awaiting confirmation: the target agent + the chosen
|
||
* profile id. `null` when no dialog is open. Switching the engine abandons the
|
||
* conversation history, so it is gated behind an explicit confirmation.
|
||
*/
|
||
const [pendingProfileChange, setPendingProfileChange] = useState<{
|
||
agentId: string;
|
||
profileId: string;
|
||
} | null>(null);
|
||
const [pendingWriterChange, setPendingWriterChange] = useState<{
|
||
agentId: string;
|
||
agentName: string;
|
||
currentWriterName: string;
|
||
} | null>(null);
|
||
|
||
async function confirmProfileChange() {
|
||
if (!pendingProfileChange) return;
|
||
const { agentId, profileId } = pendingProfileChange;
|
||
setPendingProfileChange(null);
|
||
// Use the running agent terminal's size when available; fall back to a sane
|
||
// default (the backend only needs these for a possible hot relaunch).
|
||
const relaunched = await vm.changeAgentProfile(agentId, profileId, 24, 80);
|
||
// On a hot relaunch the backend mints a fresh session; rebind the panel's
|
||
// terminal to it and drop any persisted conversation id for this cell (the
|
||
// history is gone — the front reflects the swap for the current session).
|
||
if (relaunched && agentId === activeAgentId) {
|
||
setAgentSessions((prev) => ({ ...prev, [agentId]: relaunched.sessionId }));
|
||
}
|
||
}
|
||
|
||
function requestWriterChange(agent: Agent) {
|
||
if (agent.isOrchestrator || vm.busy) return;
|
||
const currentWriter = vm.agents.find((candidate) => candidate.isOrchestrator);
|
||
if (currentWriter && currentWriter.id !== agent.id) {
|
||
setPendingWriterChange({
|
||
agentId: agent.id,
|
||
agentName: agent.name,
|
||
currentWriterName: currentWriter.name,
|
||
});
|
||
return;
|
||
}
|
||
void vm.updateProjectOrchestrator(agent.id);
|
||
}
|
||
|
||
async function confirmWriterChange() {
|
||
if (!pendingWriterChange) return;
|
||
const agentId = pendingWriterChange.agentId;
|
||
setPendingWriterChange(null);
|
||
await vm.updateProjectOrchestrator(agentId);
|
||
}
|
||
|
||
const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null;
|
||
const selectedTemplateMissingProfile =
|
||
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
|
||
const canCreate =
|
||
newName.trim().length > 0 && !vm.busy && !selectedTemplateMissingProfile;
|
||
|
||
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,
|
||
});
|
||
publishAgentRosterChanged(projectId);
|
||
// 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 handleTemplateChange(templateId: string) {
|
||
setNewTemplateId(templateId);
|
||
const next = templates.find((template) => template.id === templateId);
|
||
if (next?.defaultAgentName?.trim()) {
|
||
setNewName(next.defaultAgentName.trim());
|
||
}
|
||
}
|
||
|
||
function handleLaunch(agentId: string) {
|
||
setPanelNodeIds((prev) => ({
|
||
...prev,
|
||
[agentId]: prev[agentId] ?? crypto.randomUUID(),
|
||
}));
|
||
setActiveAgentId(agentId);
|
||
}
|
||
|
||
async function handleStop(agentId: string) {
|
||
setStopStateByAgent((current) => ({ ...current, [agentId]: "stopping" }));
|
||
const stopped = await vm.stopAgent(agentId);
|
||
if (stopped) {
|
||
setActiveAgentId((current) => (current === agentId ? null : current));
|
||
setStopStateByAgent((current) => {
|
||
const next = { ...current };
|
||
delete next[agentId];
|
||
return next;
|
||
});
|
||
} else {
|
||
setStopStateByAgent((current) => ({ ...current, [agentId]: "incomplete" }));
|
||
}
|
||
}
|
||
|
||
// Determine if a template is chosen → profile selector is hidden (template imposes it).
|
||
const hasTemplate = newTemplateId !== "";
|
||
const selectedAgent = vm.agents.find((a) => a.id === vm.selectedAgentId) ?? null;
|
||
|
||
const profileLabel = (profile: AgentProfile): string => {
|
||
const model =
|
||
profile.model ??
|
||
profile.opencode?.model ??
|
||
profile.opencodeProvider?.model ??
|
||
profile.chatHttp?.model;
|
||
return model ? `${profile.name} · ${model}` : profile.name;
|
||
};
|
||
|
||
return (
|
||
<Panel title={tr("Agents", "Agents")} className="flex flex-col gap-0">
|
||
{vm.error && (
|
||
<div
|
||
role="alert"
|
||
className="mx-4 mt-3 flex flex-wrap items-center gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||
>
|
||
<span>{tr("Mise à jour interrompue.", "Update interrupted.")}</span>
|
||
<span>{vm.error}</span>
|
||
<Button size="sm" variant="secondary" onClick={() => void vm.refresh()}>
|
||
{tr("Réessayer", "Retry")}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Create form ── */}
|
||
<form
|
||
onSubmit={(e) => void handleCreate(e)}
|
||
className="flex flex-col gap-3 border-b border-border p-4"
|
||
>
|
||
<div className="flex min-w-0 flex-col gap-1">
|
||
<label
|
||
htmlFor="agent-name-input"
|
||
className="text-xs font-medium text-muted"
|
||
>
|
||
{tr("Nom", "Name")}
|
||
</label>
|
||
<Input
|
||
id="agent-name-input"
|
||
aria-label={tr("Nom de l’agent", "Agent name")}
|
||
placeholder={tr("Mon agent", "My agent")}
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
|
||
{/* Template selector */}
|
||
<div className="flex min-w-0 flex-col gap-1">
|
||
<label
|
||
htmlFor="agent-template-select"
|
||
className="text-xs font-medium text-muted"
|
||
>
|
||
{tr("Modèle", "Template")}
|
||
</label>
|
||
<SmallDropdown
|
||
id="agent-template-select"
|
||
aria-label={tr("Modèle d’agent", "Agent template")}
|
||
value={newTemplateId}
|
||
onChange={handleTemplateChange}
|
||
className="w-full"
|
||
disabled={vm.busy}
|
||
size="md"
|
||
options={[
|
||
{ value: "", label: tr("(aucun / partir de zéro)", "(none / from scratch)") },
|
||
...templates.map((t) => ({ value: t.id, label: t.name })),
|
||
]}
|
||
/>
|
||
{selectedTemplate && (
|
||
<div className="rounded-md border border-border bg-raised px-3 py-2 text-xs text-muted">
|
||
<div>
|
||
{tr("Nom initial", "Initial name")}: {" "}
|
||
<span className="text-content">
|
||
{selectedTemplate.defaultAgentName || selectedTemplate.name}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
{tr("Modèle appliqué", "Applied model")}: {" "}
|
||
<span className="text-content">
|
||
{templateProfileLabel(selectedTemplate, vm.profiles, tr)}
|
||
</span>
|
||
</div>
|
||
{selectedTemplateMissingProfile && (
|
||
<p role="alert" className="mt-1 text-danger">
|
||
{tr("Ce modèle n’a pas de profil IA par défaut. Choisissez un autre modèle ou modifiez-le avant de créer l’agent.", "This template has no default AI profile. Choose another template or edit it before creating the agent.")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Profile selector — hidden when a template is chosen */}
|
||
{!hasTemplate && (
|
||
<div className="flex min-w-0 flex-col gap-1">
|
||
<label
|
||
htmlFor="agent-profile-select"
|
||
className="text-xs font-medium text-muted"
|
||
>
|
||
{tr("Profil", "Profile")}
|
||
{vm.profiles.length === 0 && (
|
||
<span className="ml-1 text-faint">{tr("(aucun profil configuré — vous pouvez saisir un identifiant)", "(no profiles configured — you can still enter an id)")}</span>
|
||
)}
|
||
</label>
|
||
{vm.profiles.length > 0 ? (
|
||
<SmallDropdown
|
||
id="agent-profile-select"
|
||
aria-label={tr("Profil de l’agent", "Agent profile")}
|
||
value={newProfileId}
|
||
onChange={setNewProfileId}
|
||
className="w-full"
|
||
size="md"
|
||
options={[
|
||
{ value: "", label: tr("— sélectionner un profil —", "— select profile —") },
|
||
...vm.profiles.map((p) => ({
|
||
value: p.id,
|
||
label: profileLabel(p),
|
||
})),
|
||
]}
|
||
/>
|
||
) : (
|
||
<Input
|
||
id="agent-profile-select"
|
||
aria-label={tr("Profil de l’agent", "Agent profile")}
|
||
placeholder={tr("identifiant-profil (facultatif)", "profile-id (optional)")}
|
||
value={newProfileId}
|
||
onChange={(e) => setNewProfileId(e.target.value)}
|
||
className="min-w-32"
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<Button
|
||
type="submit"
|
||
variant="primary"
|
||
aria-label={tr("Créer l’agent", "Create agent")}
|
||
disabled={!canCreate}
|
||
loading={vm.busy}
|
||
className="self-end"
|
||
>
|
||
{tr("Créer", "Create")}
|
||
</Button>
|
||
</form>
|
||
|
||
{/* ── Agent list ── */}
|
||
<div className="p-4">
|
||
{vm.agents.length === 0 ? (
|
||
<p className="text-sm text-muted">{tr("Aucun agent pour le moment.", "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 stopState = stopStateByAgent[a.id];
|
||
const live = vm.liveAgents.find((candidate) => candidate.agentId === a.id);
|
||
const agentProfile = vm.profiles.find((p) => p.id === a.profileId) ?? null;
|
||
const profileName =
|
||
(agentProfile ? profileLabel(agentProfile) : null) ?? a.profileId;
|
||
const agentDrift = drift.driftByAgentId.get(a.id);
|
||
// Source of this agent's last orchestration delegation (mcp vs
|
||
// file), if any has been observed. Absent ⇒ no badge.
|
||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||
const limitState = vm.limitByAgent[a.id];
|
||
// F35 — local model server launch state, correlated from the
|
||
// agent's profile binding (`opencode.localModelServerId`), plus the
|
||
// agent's last launch failure (if any).
|
||
const modelServerStatus = correlateModelServerStatus(
|
||
a,
|
||
vm.profiles,
|
||
vm.modelServerStatusByServer,
|
||
);
|
||
const launchFailure = vm.launchFailureByAgent[a.id];
|
||
const network = networkByAgent[a.id] ?? null;
|
||
return (
|
||
<li
|
||
key={a.id}
|
||
className={cn(
|
||
"flex flex-col gap-2 py-3 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-col items-start gap-0.5 text-left"
|
||
aria-pressed={isSelected}
|
||
>
|
||
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
||
<span className="font-medium text-content">{a.name}</span>
|
||
{a.isOrchestrator && (
|
||
<span
|
||
aria-label={tr("Responsable du contexte", "Context writer")}
|
||
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
|
||
>
|
||
{tr("Responsable du contexte", "Context writer")}
|
||
</span>
|
||
)}
|
||
{agentDrift && (
|
||
<span
|
||
aria-label={tr("Mise à jour disponible", "Update available")}
|
||
className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
|
||
>
|
||
{tr("mise à jour disponible", "update available")}
|
||
</span>
|
||
)}
|
||
{delegationSource && (
|
||
<span
|
||
aria-label={tr(`source de délégation ${delegationSource}`, `delegation source ${delegationSource}`)}
|
||
title={
|
||
delegationSource === "mcp"
|
||
? tr("La dernière délégation est arrivée via le serveur MCP", "Last delegation arrived via the MCP server")
|
||
: tr("La dernière délégation est arrivée via .ideai/requests", "Last delegation arrived via .ideai/requests")
|
||
}
|
||
className="rounded-full bg-raised px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted"
|
||
>
|
||
{delegationSource}
|
||
</span>
|
||
)}
|
||
{network && <NetworkPermissionBadge state={network} />}
|
||
</span>
|
||
<span className="text-xs text-muted">{profileName}</span>
|
||
{live && (
|
||
<span className="block w-full min-w-0 truncate text-xs text-primary">
|
||
{tr("actif dans IdeA", "running in IdeA")} · {live.sessionId ?? live.nodeId}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{isSelected && (
|
||
<section
|
||
aria-label={`${tr("Rôle dans le projet", "Role in project")} — ${a.name}`}
|
||
className="rounded-md border border-border bg-surface p-3"
|
||
>
|
||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||
{tr("Rôle dans le projet", "Role in project")}
|
||
</h4>
|
||
<label className="mt-2 flex cursor-pointer items-start gap-2">
|
||
<input
|
||
type="radio"
|
||
name="project-context-writer"
|
||
aria-label={tr(`Responsable du contexte global du projet pour ${a.name}`, `Global project context writer for ${a.name}`)}
|
||
checked={a.isOrchestrator === true}
|
||
disabled={vm.busy}
|
||
onChange={() => requestWriterChange(a)}
|
||
className="mt-0.5"
|
||
/>
|
||
<span className="text-sm font-medium text-content">
|
||
{tr("Responsable du contexte global du projet", "Global project context writer")}
|
||
</span>
|
||
</label>
|
||
<p className="mt-1 pl-5 text-xs text-muted">
|
||
{tr("Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.", "Can directly edit the project’s shared context. Only one agent can have this role.")}
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
{limitState && (
|
||
<AgentLimitBadge
|
||
state={limitState}
|
||
busy={vm.busy}
|
||
onCancelResume={() => void vm.cancelResume(a.id)}
|
||
onSetResumeAt={(resetsAtMs) =>
|
||
void vm.setResumeAt(a.id, resetsAtMs)
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* F35 — local model server launch state / actionable failure. */}
|
||
<ModelServerLaunchBadge
|
||
status={modelServerStatus}
|
||
failure={launchFailure}
|
||
/>
|
||
|
||
{/* F2 (ticket #4): announcements this agent is waiting on while
|
||
it talks to another agent (requester == this row's id). Sits
|
||
just above the agent drop-list. */}
|
||
<AnnouncementsPreview requester={a.id} />
|
||
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{/* Profile hot-swap selector (Chantier A). Changing the
|
||
engine abandons the conversation history → confirmation. */}
|
||
{vm.profiles.length > 0 && (
|
||
<SmallDropdown
|
||
aria-label={tr(`Profil de ${a.name}`, `Profile for ${a.name}`)}
|
||
value={a.profileId}
|
||
disabled={vm.busy}
|
||
onChange={(profileId) => {
|
||
if (profileId && profileId !== a.profileId) {
|
||
setPendingProfileChange({ agentId: a.id, profileId });
|
||
}
|
||
}}
|
||
options={[
|
||
...(vm.profiles.every((p) => p.id !== a.profileId)
|
||
? [{ value: a.profileId, label: a.profileId }]
|
||
: []),
|
||
...vm.profiles.map((p) => ({
|
||
value: p.id,
|
||
label: profileLabel(p),
|
||
})),
|
||
]}
|
||
/>
|
||
)}
|
||
<EffortSelector
|
||
agent={a}
|
||
profile={agentProfile}
|
||
busy={vm.busy}
|
||
onChange={(effort) =>
|
||
void vm.updateAgentEffort(a.id, effort)
|
||
}
|
||
/>
|
||
{agentDrift && (
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
aria-label={tr(`Synchroniser ${a.name}`, `Sync ${a.name}`)}
|
||
disabled={vm.busy || drift.driftBusy}
|
||
onClick={() =>
|
||
void drift.syncAgent(a.id, vm.refresh)
|
||
}
|
||
>
|
||
{tr("Synchroniser", "Sync")}
|
||
</Button>
|
||
)}
|
||
{isRunning || live ? (
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label={
|
||
stopState === "stopping"
|
||
? tr(`Arrêt en cours… — ${a.name}`, `Stopping… — ${a.name}`)
|
||
: stopState === "incomplete"
|
||
? tr(`Réessayer l’arrêt — ${a.name}`, `Retry stopping — ${a.name}`)
|
||
: tr(`Arrêter ${a.name}`, `Stop ${a.name}`)
|
||
}
|
||
disabled={stopState === "stopping"}
|
||
onClick={() => void handleStop(a.id)}
|
||
className="text-danger hover:text-danger"
|
||
>
|
||
{stopState === "stopping"
|
||
? tr("Arrêt en cours…", "Stopping…")
|
||
: stopState === "incomplete"
|
||
? tr("Réessayer l’arrêt", "Retry stopping")
|
||
: tr("Arrêter", "Stop")}
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
size="sm"
|
||
variant="primary"
|
||
aria-label={tr(`Lancer ${a.name}`, `Launch ${a.name}`)}
|
||
disabled={vm.busy || activeAgentId !== null}
|
||
onClick={() => handleLaunch(a.id)}
|
||
>
|
||
{tr("Lancer", "Launch")}
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label={tr(`Supprimer ${a.name}`, `Delete ${a.name}`)}
|
||
disabled={vm.busy}
|
||
onClick={() => void vm.deleteAgent(a.id)}
|
||
className="text-danger hover:text-danger"
|
||
>
|
||
{tr("Supprimer", "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
|
||
key={activeAgentId}
|
||
cwd={projectRoot}
|
||
open={(opts, onData) =>
|
||
vm.launchAgent(
|
||
activeAgentId,
|
||
{ ...opts, nodeId: panelNodeIds[activeAgentId] },
|
||
onData,
|
||
)
|
||
}
|
||
reattach={(sessionId, onData) =>
|
||
gateways.agent.reattach(sessionId, onData)
|
||
}
|
||
sessionId={agentSessions[activeAgentId] ?? null}
|
||
onSessionId={(sid) =>
|
||
setAgentSessions((prev) => ({ ...prev, [activeAgentId]: sid }))
|
||
}
|
||
systemPermissions={networkByAgent[activeAgentId] ?? null}
|
||
/>
|
||
</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">
|
||
{tr("Contexte", "Context")} — {selectedAgent.name}
|
||
</h4>
|
||
<code className="text-xs text-muted">{selectedAgent.contextPath}</code>
|
||
</div>
|
||
<textarea
|
||
aria-label={tr("Contexte de l’agent", "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)}
|
||
>
|
||
{tr("Enregistrer", "Save")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Profile-change confirmation dialog ── */}
|
||
{pendingProfileChange && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||
>
|
||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||
<h4 className="text-sm font-semibold text-content">
|
||
{tr("Changer le moteur IA", "Change AI engine")}
|
||
</h4>
|
||
<p className="mt-2 text-sm text-muted">
|
||
{tr("Changer le moteur abandonne l’historique de conversation (le contexte et la mémoire sont conservés). Continuer ?", "Changing the engine discards conversation history (context and memory are preserved). Continue?")}
|
||
</p>
|
||
<div className="mt-4 flex items-center justify-end gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
aria-label={tr("Annuler le changement de profil", "Cancel profile change")}
|
||
disabled={vm.busy}
|
||
onClick={() => setPendingProfileChange(null)}
|
||
>
|
||
{tr("Annuler", "Cancel")}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
aria-label={tr("Confirmer le changement de profil", "Confirm profile change")}
|
||
loading={vm.busy}
|
||
disabled={vm.busy}
|
||
onClick={() => void confirmProfileChange()}
|
||
>
|
||
{tr("Continuer", "Continue")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{pendingWriterChange && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
|
||
>
|
||
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
|
||
<h4 className="text-sm font-semibold text-content">
|
||
{tr("Changer le responsable du contexte", "Change context writer")}
|
||
</h4>
|
||
<p className="mt-2 text-sm text-muted">
|
||
{tr(`Le rôle sera retiré de ${pendingWriterChange.currentWriterName} et attribué à ${pendingWriterChange.agentName}. Continuer ?`, `The role will be removed from ${pendingWriterChange.currentWriterName} and assigned to ${pendingWriterChange.agentName}. Continue?`)}
|
||
</p>
|
||
<div className="mt-4 flex items-center justify-end gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
aria-label={tr("Annuler le changement de responsable du contexte", "Cancel context writer change")}
|
||
disabled={vm.busy}
|
||
onClick={() => setPendingWriterChange(null)}
|
||
>
|
||
{tr("Annuler", "Cancel")}
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
aria-label={tr("Confirmer le changement de responsable du contexte", "Confirm context writer change")}
|
||
loading={vm.busy}
|
||
disabled={vm.busy}
|
||
onClick={() => void confirmWriterChange()}
|
||
>
|
||
{tr("Continuer", "Continue")}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
const EFFORT_DEFAULT_VALUE = "__profile_default__";
|
||
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
|
||
|
||
function genericEffortOptions(tr: (fr: string, en: string) => string): EffortOption[] {
|
||
return [
|
||
{ value: "low", label: tr("Rapide (par défaut)", "Fast (default)"), hint: tr("Réglage générique léger.", "Light generic fallback.") },
|
||
{ value: "medium", label: tr("Standard (par défaut)", "Standard (default)"), hint: tr("Réglage générique équilibré.", "Balanced generic fallback.") },
|
||
{ value: "high", label: tr("Approfondi (par défaut)", "Thorough (default)"), hint: tr("Réglage générique approfondi.", "Deep generic fallback.") },
|
||
];
|
||
}
|
||
|
||
function rawEffortValue(selection: EffortSelection | undefined): string {
|
||
return selection?.value ?? "";
|
||
}
|
||
|
||
function EffortSelector({
|
||
agent,
|
||
profile,
|
||
busy,
|
||
onChange,
|
||
}: {
|
||
agent: Agent;
|
||
profile: AgentProfile | null;
|
||
busy: boolean;
|
||
onChange: (effort: EffortSelection | null) => void;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
const nativeOptions = profile?.effortOptions ?? [];
|
||
const hasNativeOptions = nativeOptions.length > 0;
|
||
const displayedOptions = hasNativeOptions
|
||
? nativeOptions
|
||
: genericEffortOptions(tr);
|
||
const [customText, setCustomText] = useState(rawEffortValue(agent.effort));
|
||
const [forceCustom, setForceCustom] = useState(agent.effort?.kind === "custom");
|
||
|
||
useEffect(() => {
|
||
setCustomText(rawEffortValue(agent.effort));
|
||
setForceCustom(agent.effort?.kind === "custom");
|
||
}, [agent.id, agent.effort]);
|
||
|
||
let selectedOption = EFFORT_DEFAULT_VALUE;
|
||
if (forceCustom) {
|
||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||
} else if (
|
||
hasNativeOptions &&
|
||
agent.effort?.kind === "preset" &&
|
||
nativeOptions.some((option) => option.value === agent.effort?.value)
|
||
) {
|
||
selectedOption = `preset:${agent.effort.value}`;
|
||
} else if (
|
||
!hasNativeOptions &&
|
||
agent.effort &&
|
||
displayedOptions.some((option) => option.value === agent.effort?.value)
|
||
) {
|
||
selectedOption = `fallback:${agent.effort.value}`;
|
||
} else if (agent.effort) {
|
||
selectedOption = EFFORT_CUSTOM_VALUE;
|
||
}
|
||
|
||
const customVisible = selectedOption === EFFORT_CUSTOM_VALUE;
|
||
|
||
function commitCustom() {
|
||
const value = customText.trim();
|
||
if (value.length === 0) return;
|
||
if (agent.effort?.kind === "custom" && agent.effort.value === value) return;
|
||
onChange({ kind: "custom", value });
|
||
}
|
||
|
||
return (
|
||
<span className="flex min-w-[11rem] max-w-full flex-wrap items-center gap-1.5">
|
||
<SmallDropdown
|
||
aria-label={tr(`Effort pour ${agent.name}`, `Effort for ${agent.name}`)}
|
||
value={selectedOption}
|
||
disabled={busy}
|
||
onChange={(value) => {
|
||
if (value === EFFORT_DEFAULT_VALUE) {
|
||
setForceCustom(false);
|
||
onChange(null);
|
||
return;
|
||
}
|
||
if (value === EFFORT_CUSTOM_VALUE) {
|
||
setForceCustom(true);
|
||
setCustomText(rawEffortValue(agent.effort));
|
||
return;
|
||
}
|
||
if (value.startsWith("preset:")) {
|
||
setForceCustom(false);
|
||
onChange({ kind: "preset", value: value.slice("preset:".length) });
|
||
return;
|
||
}
|
||
if (value.startsWith("fallback:")) {
|
||
setForceCustom(false);
|
||
onChange({ kind: "custom", value: value.slice("fallback:".length) });
|
||
}
|
||
}}
|
||
options={[
|
||
{ value: EFFORT_DEFAULT_VALUE, label: tr("Effort : profil", "Effort: profile") },
|
||
...displayedOptions.map((option) => ({
|
||
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
|
||
label: option.label,
|
||
})),
|
||
{ value: EFFORT_CUSTOM_VALUE, label: tr("Personnalisé", "Custom") },
|
||
]}
|
||
/>
|
||
{customVisible && (
|
||
<Input
|
||
aria-label={tr(`Effort personnalisé pour ${agent.name}`, `Custom effort for ${agent.name}`)}
|
||
value={customText}
|
||
disabled={busy}
|
||
placeholder={tr("valeur brute", "raw value")}
|
||
onChange={(event) => setCustomText(event.target.value)}
|
||
onBlur={commitCustom}
|
||
onKeyDown={(event) => {
|
||
event.stopPropagation();
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
commitCustom();
|
||
}
|
||
}}
|
||
className="h-8 min-w-[8rem] flex-1 px-2 text-xs"
|
||
/>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function NetworkPermissionBadge({
|
||
state,
|
||
}: {
|
||
state: ResolvedAgentSystemPermissions;
|
||
}) {
|
||
const { tr } = useI18n();
|
||
const locked = state.runtimeLock.state === "locked";
|
||
const label = locked
|
||
? tr("Verrouillé", "Locked")
|
||
: state.effective === "allow"
|
||
? tr("Autorisé", "Allowed")
|
||
: state.effective === "deny"
|
||
? tr("Interdit", "Denied")
|
||
: tr("Demande", "Ask");
|
||
return (
|
||
<span
|
||
aria-label={`${tr("Réseau", "Network")} ${label}`}
|
||
title={
|
||
locked
|
||
? (state.runtimeLock.reason ?? tr("Réseau verrouillé par l’exécution", "Network locked by runtime"))
|
||
: `${tr("État effectif", "Effective state")} : ${label}`
|
||
}
|
||
className={cn(
|
||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||
locked
|
||
? "bg-warning/15 text-warning"
|
||
: state.effective === "allow"
|
||
? "bg-success/15 text-success"
|
||
: state.effective === "deny"
|
||
? "bg-danger/15 text-danger"
|
||
: "bg-primary/15 text-primary",
|
||
)}
|
||
>
|
||
{tr("Réseau", "Network")}: {label}
|
||
</span>
|
||
);
|
||
}
|