Files
IdeA/frontend/src/features/agents/AgentsPanel.tsx

933 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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>
);
}