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

912 lines
33 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 { 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 { 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 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 [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,
});
// 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);
}
function handleStop(agentId?: string) {
void vm.stopAgent(agentId);
setActiveAgentId(null);
}
// 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="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-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"
>
Name
</label>
<Input
id="agent-name-input"
aria-label="agent name"
placeholder="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"
>
Template
</label>
<SmallDropdown
id="agent-template-select"
aria-label="agent template"
value={newTemplateId}
onChange={handleTemplateChange}
className="w-full"
disabled={vm.busy}
size="md"
options={[
{ value: "", label: "(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>
Nom initial:{" "}
<span className="text-content">
{selectedTemplate.defaultAgentName || selectedTemplate.name}
</span>
</div>
<div>
Modèle appliqué:{" "}
<span className="text-content">
{templateProfileLabel(selectedTemplate, vm.profiles)}
</span>
</div>
{selectedTemplateMissingProfile && (
<p role="alert" className="mt-1 text-danger">
Ce template n'a pas de modèle AI par défaut. Choisis un autre
template ou édite-le avant de créer l'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"
>
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 ? (
<SmallDropdown
id="agent-profile-select"
aria-label="agent profile"
value={newProfileId}
onChange={setNewProfileId}
className="w-full"
size="md"
options={[
{ value: "", label: "— select profile —" },
...vm.profiles.map((p) => ({
value: p.id,
label: profileLabel(p),
})),
]}
/>
) : (
<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}
className="self-end"
>
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 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="Writer contexte"
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
>
Writer contexte
</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>
)}
{delegationSource && (
<span
aria-label={`delegation source ${delegationSource}`}
title={
delegationSource === "mcp"
? "Last delegation arrived via the MCP server"
: "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">
running in IdeA · {live.sessionId ?? live.nodeId}
</span>
)}
</button>
{isSelected && (
<section
aria-label={`Rôle dans le projet — ${a.name}`}
className="rounded-md border border-border bg-surface p-3"
>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
Rôle dans le projet
</h4>
<label className="mt-2 flex cursor-pointer items-start gap-2">
<input
type="radio"
name="project-context-writer"
aria-label={`Writer du contexte projet global pour ${a.name}`}
checked={a.isOrchestrator === true}
disabled={vm.busy}
onChange={() => requestWriterChange(a)}
className="mt-0.5"
/>
<span className="text-sm font-medium text-content">
Writer du contexte projet global
</span>
</label>
<p className="mt-1 pl-5 text-xs text-muted">
Peut modifier directement le contexte partagé du projet.
Un seul agent peut avoir ce rôle.
</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={`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={`sync ${a.name}`}
disabled={vm.busy || drift.driftBusy}
onClick={() =>
void drift.syncAgent(a.id, vm.refresh)
}
>
Sync
</Button>
)}
{isRunning || live ? (
<Button
size="sm"
variant="ghost"
aria-label={`stop ${a.name}`}
onClick={() => handleStop(a.id)}
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
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">
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>
)}
{/* ── Profile-change confirmation dialog ── */}
{pendingProfileChange && (
<div
role="dialog"
aria-modal="true"
aria-label="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">
Changer le moteur IA
</h4>
<p className="mt-2 text-sm text-muted">
Changer le moteur abandonne l'historique de conversation (le
contexte et la mémoire sont conservés). Continuer&nbsp;?
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="cancel profile change"
disabled={vm.busy}
onClick={() => setPendingProfileChange(null)}
>
Annuler
</Button>
<Button
variant="primary"
aria-label="confirm profile change"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmProfileChange()}
>
Continuer
</Button>
</div>
</div>
</div>
)}
{pendingWriterChange && (
<div
role="dialog"
aria-modal="true"
aria-label="Confirmer le changement de writer contexte"
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">
Changer le writer du contexte
</h4>
<p className="mt-2 text-sm text-muted">
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
attribué à {pendingWriterChange.agentName}. Continuer&nbsp;?
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="Annuler le changement de writer contexte"
disabled={vm.busy}
onClick={() => setPendingWriterChange(null)}
>
Annuler
</Button>
<Button
variant="primary"
aria-label="Confirmer le changement de writer contexte"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmWriterChange()}
>
Continuer
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}
const EFFORT_DEFAULT_VALUE = "__profile_default__";
const EFFORT_CUSTOM_VALUE = "__custom_effort__";
const GENERIC_EFFORT_OPTIONS: EffortOption[] = [
{
value: "low",
label: "Rapide (par défaut)",
hint: "Fallback générique léger.",
},
{
value: "medium",
label: "Standard (par défaut)",
hint: "Fallback générique équilibré.",
},
{
value: "high",
label: "Approfondi (par défaut)",
hint: "Fallback générique profond.",
},
];
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 nativeOptions = profile?.effortOptions ?? [];
const hasNativeOptions = nativeOptions.length > 0;
const displayedOptions = hasNativeOptions
? nativeOptions
: GENERIC_EFFORT_OPTIONS;
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={`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: "Effort: profil" },
...displayedOptions.map((option) => ({
value: `${hasNativeOptions ? "preset" : "fallback"}:${option.value}`,
label: option.label,
})),
{ value: EFFORT_CUSTOM_VALUE, label: "Personnalisé" },
]}
/>
{customVisible && (
<Input
aria-label={`custom effort for ${agent.name}`}
value={customText}
disabled={busy}
placeholder="valeur brute"
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 locked = state.runtimeLock.state === "locked";
const label = locked
? "Verrouillé"
: state.effective === "allow"
? "Autorisé"
: state.effective === "deny"
? "Interdit"
: "Demande";
return (
<span
aria-label={`network ${label}`}
title={
locked
? (state.runtimeLock.reason ?? "Réseau verrouillé par le runtime")
: `État effectif : ${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",
)}
>
Réseau: {label}
</span>
);
}