Introduit SmallDropdown comme composant partagé et le fait adopter par AgentsPanel, ModelServerSelect, OpenCodeModeFields, TemplateEditor et TicketViewportSelect, pour que toutes les listes déroulantes dynamiques (agent, modèle, template, viewport) partagent la même apparence que le sélecteur d'agent de la création de ticket. Refs #114 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
745 lines
27 KiB
TypeScript
745 lines
27 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 { useGateways } from "@/app/di";
|
|
import { useAgents } from "./useAgents";
|
|
import { correlateModelServerStatus } from "./modelServerLaunch";
|
|
import { AgentLimitBadge } from "./AgentLimitBadge";
|
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
|
import type { 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(() => {});
|
|
}
|
|
|
|
// Skills available for assignment (global + project), loaded lazily once.
|
|
const skillGw = gateways.skill ?? null;
|
|
const [skills, setSkills] = useState<import("@/domain").Skill[]>([]);
|
|
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
|
const [skillToAssign, setSkillToAssign] = useState("");
|
|
|
|
async function refreshSkills() {
|
|
if (!skillGw) return;
|
|
try {
|
|
const [globals, projects] = await Promise.all([
|
|
skillGw.listSkills(projectId, "global"),
|
|
skillGw.listSkills(projectId, "project"),
|
|
]);
|
|
setSkills([...projects, ...globals]);
|
|
} catch {
|
|
// Skills are optional — leave the list empty on failure.
|
|
}
|
|
}
|
|
|
|
if (!skillsLoaded && skillGw) {
|
|
setSkillsLoaded(true);
|
|
void refreshSkills();
|
|
}
|
|
|
|
async function handleAssignSkill() {
|
|
if (!skillGw || !vm.selectedAgentId || !skillToAssign) return;
|
|
const target = skills.find((s) => s.id === skillToAssign);
|
|
if (!target) return;
|
|
await skillGw.assignSkill(
|
|
projectId,
|
|
vm.selectedAgentId,
|
|
target.id,
|
|
target.scope,
|
|
);
|
|
setSkillToAssign("");
|
|
await vm.refresh();
|
|
}
|
|
|
|
async function handleUnassignSkill(skillId: string) {
|
|
if (!skillGw || !vm.selectedAgentId) return;
|
|
await skillGw.unassignSkill(projectId, vm.selectedAgentId, skillId);
|
|
await vm.refresh();
|
|
}
|
|
|
|
// 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);
|
|
|
|
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 }));
|
|
}
|
|
}
|
|
|
|
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) {
|
|
setPanelNodeIds((prev) => ({
|
|
...prev,
|
|
[agentId]: prev[agentId] ?? crypto.randomUUID(),
|
|
}));
|
|
setActiveAgentId(agentId);
|
|
}
|
|
|
|
function handleStop(agentId?: string) {
|
|
void vm.stopAgent(agentId);
|
|
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 !== "";
|
|
|
|
const profileLabel = (profile: import("@/domain").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={setNewTemplateId}
|
|
className="w-full"
|
|
disabled={vm.busy}
|
|
size="md"
|
|
options={[
|
|
{ value: "", label: "(none / from scratch)" },
|
|
...templates.map((t) => ({ value: t.id, label: t.name })),
|
|
]}
|
|
/>
|
|
</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 profileName =
|
|
(() => {
|
|
const p = vm.profiles.find((p) => p.id === a.profileId);
|
|
return p ? profileLabel(p) : 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>
|
|
{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>
|
|
|
|
{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),
|
|
})),
|
|
]}
|
|
/>
|
|
)}
|
|
{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>
|
|
)}
|
|
|
|
{/* ── Skill assignment ── */}
|
|
{selectedAgent && skillGw && (
|
|
<div className="flex flex-col gap-2 border-t border-border p-4">
|
|
<h4 className="text-sm font-semibold text-content">
|
|
Skills — {selectedAgent.name}
|
|
</h4>
|
|
|
|
{/* Assigned skills */}
|
|
{selectedAgent.skills.length === 0 ? (
|
|
<p className="text-xs text-muted">No skills assigned.</p>
|
|
) : (
|
|
<ul className="flex flex-wrap gap-1.5">
|
|
{selectedAgent.skills.map((ref) => {
|
|
const name =
|
|
skills.find((s) => s.id === ref.skillId)?.name ?? ref.skillId;
|
|
return (
|
|
<li
|
|
key={ref.skillId}
|
|
className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content"
|
|
>
|
|
<span>{name}</span>
|
|
<button
|
|
type="button"
|
|
aria-label={`unassign ${name}`}
|
|
disabled={vm.busy}
|
|
onClick={() => void handleUnassignSkill(ref.skillId)}
|
|
className="text-faint hover:text-danger"
|
|
>
|
|
✕
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Assign selector */}
|
|
<div className="flex items-end gap-2">
|
|
<SmallDropdown
|
|
aria-label="skill to assign"
|
|
value={skillToAssign}
|
|
onChange={setSkillToAssign}
|
|
disabled={vm.busy}
|
|
className="min-w-0 flex-1"
|
|
size="md"
|
|
options={[
|
|
{ value: "", label: "— assign a skill —" },
|
|
...skills
|
|
.filter(
|
|
(s) =>
|
|
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
|
)
|
|
.map((s) => ({
|
|
value: s.id,
|
|
label: `${s.name} (${s.scope})`,
|
|
})),
|
|
]}
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
aria-label="assign skill"
|
|
disabled={vm.busy || !skillToAssign}
|
|
onClick={() => void handleAssignSkill()}
|
|
>
|
|
Assign
|
|
</Button>
|
|
</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 ?
|
|
</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>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|