Sprint « Modeles locaux », couche frontend. #35 : - F35.1 badge de statut de lancement du serveur local (ModelServerLaunchBadge + useAgentsModelServer). - F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers / gateway modelServer) et ModelServerSelect. #36 : - Liste multi-profils OpenCode dans le wizard de premier lancement, gateway de clonage (clone_opencode_profile_from_seed). Tests verts (exécution réelle) : tsc propre, vitest 608/608. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
669 lines
25 KiB
TypeScript
669 lines
25 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 { useState } from "react";
|
|
|
|
import { Button, Input, Panel, 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 { AgentLimitBadge } from "./AgentLimitBadge";
|
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
|
|
|
export interface AgentsPanelProps {
|
|
/** The project whose agents to manage. */
|
|
projectId: string;
|
|
/**
|
|
* The filesystem root of the project. Passed as the `cwd` to the agent
|
|
* terminal. Supplied by `ProjectsView` which has `active.root`.
|
|
*/
|
|
projectRoot?: string;
|
|
}
|
|
|
|
export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|
const vm = useAgents(projectId);
|
|
const drift = useDrift(projectId);
|
|
const gateways = useGateways();
|
|
const templateGw = gateways.template ?? null;
|
|
|
|
// Create form state
|
|
const [newName, setNewName] = useState("");
|
|
const [newProfileId, setNewProfileId] = useState("");
|
|
const [newTemplateId, setNewTemplateId] = useState("");
|
|
|
|
// Templates available for the selector (loaded lazily on first render via a
|
|
// separate effect handled inline via hook state)
|
|
const [templates, setTemplates] = useState<import("@/domain").Template[]>([]);
|
|
const [templatesLoaded, setTemplatesLoaded] = useState(false);
|
|
|
|
// Load templates once on mount (best-effort — if it fails the selector just stays empty)
|
|
if (!templatesLoaded && templateGw) {
|
|
setTemplatesLoaded(true);
|
|
void templateGw.listTemplates().then(setTemplates).catch(() => {});
|
|
}
|
|
|
|
// 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>>(
|
|
{},
|
|
);
|
|
|
|
/**
|
|
* 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 !== "";
|
|
|
|
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>
|
|
<select
|
|
id="agent-template-select"
|
|
aria-label="agent template"
|
|
value={newTemplateId}
|
|
onChange={(e) => setNewTemplateId(e.target.value)}
|
|
className={cn(
|
|
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
|
"border border-border outline-none transition-colors",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
disabled={vm.busy}
|
|
>
|
|
<option value="">(none / from scratch)</option>
|
|
{templates.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Profile selector — hidden when a template is chosen */}
|
|
{!hasTemplate && (
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
<label
|
|
htmlFor="agent-profile-select"
|
|
className="text-xs font-medium text-muted"
|
|
>
|
|
Profile
|
|
{vm.profiles.length === 0 && (
|
|
<span className="ml-1 text-faint">(no profiles configured — you can still enter an id)</span>
|
|
)}
|
|
</label>
|
|
{vm.profiles.length > 0 ? (
|
|
<select
|
|
id="agent-profile-select"
|
|
aria-label="agent profile"
|
|
value={newProfileId}
|
|
onChange={(e) => setNewProfileId(e.target.value)}
|
|
className={cn(
|
|
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
|
"border border-border outline-none transition-colors",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
<option value="">— select profile —</option>
|
|
{vm.profiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<Input
|
|
id="agent-profile-select"
|
|
aria-label="agent profile"
|
|
placeholder="profile-id (optional)"
|
|
value={newProfileId}
|
|
onChange={(e) => setNewProfileId(e.target.value)}
|
|
className="min-w-32"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
aria-label="create agent"
|
|
disabled={!canCreate}
|
|
loading={vm.busy}
|
|
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 =
|
|
vm.profiles.find((p) => p.id === a.profileId)?.name ??
|
|
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 boundServerId = vm.profiles.find((p) => p.id === a.profileId)
|
|
?.opencode?.localModelServerId;
|
|
const modelServerStatus = boundServerId
|
|
? vm.modelServerStatusByServer[boundServerId]
|
|
: undefined;
|
|
const launchFailure = vm.launchFailureByAgent[a.id];
|
|
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>
|
|
)}
|
|
</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 && (
|
|
<select
|
|
aria-label={`profile for ${a.name}`}
|
|
value={a.profileId}
|
|
disabled={vm.busy}
|
|
onChange={(e) => {
|
|
const profileId = e.target.value;
|
|
if (profileId && profileId !== a.profileId) {
|
|
setPendingProfileChange({ agentId: a.id, profileId });
|
|
}
|
|
}}
|
|
className={cn(
|
|
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
|
"border border-border outline-none transition-colors",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
{vm.profiles.every((p) => p.id !== a.profileId) && (
|
|
<option value={a.profileId}>{a.profileId}</option>
|
|
)}
|
|
{vm.profiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{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 }))
|
|
}
|
|
/>
|
|
</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">
|
|
<select
|
|
aria-label="skill to assign"
|
|
value={skillToAssign}
|
|
onChange={(e) => setSkillToAssign(e.target.value)}
|
|
disabled={vm.busy}
|
|
className={cn(
|
|
"h-9 min-w-0 flex-1 rounded-md bg-raised px-3 text-sm text-content",
|
|
"border border-border outline-none transition-colors",
|
|
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
|
)}
|
|
>
|
|
<option value="">— assign a skill —</option>
|
|
{skills
|
|
.filter(
|
|
(s) =>
|
|
!selectedAgent.skills.some((r) => r.skillId === s.id),
|
|
)
|
|
.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name} ({s.scope})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<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>
|
|
);
|
|
}
|