Files
IdeA/frontend/src/features/model-servers/ModelServersPanel.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

729 lines
26 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.

/**
* `ModelServersPanel` (F35, wizard V2) — declare / edit / delete the local
* `llama.cpp` servers an OpenCode profile can bind to. Presentational: all I/O
* state comes from a {@link ModelServersViewModel} passed in (`useModelServers`),
* so the panel renders and is testable in isolation.
*
* The editor is a plain-language wizard: a source segmented control (download
* from Hugging Face vs. a local `.gguf` file), a shared "model name in IdeA"
* field, the server settings, a collapsed advanced section for the raw
* llama.cpp knobs, and a read-only command preview built by the backend (never
* reconstructed here).
*/
import { useEffect, useRef, useState } from "react";
import type {
LocalModelServerConfig,
ModelArtifact,
ModelServerCommandPreview,
ModelSource,
StopPolicy,
} from "@/domain";
import { Button, IconButton, Input, Panel, cn } from "@/shared";
import { useI18n } from "@/i18n";
import type { ModelServersViewModel } from "./useModelServers";
import {
deriveServedModelName,
emptyModelServer,
parseArgs,
reservedFlagsIn,
validateModelServer,
type ModelServerErrors,
type ModelSourceKind,
} from "./modelServer";
/** Small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
/** Muted helper line under a control. */
function Hint({ children }: { children: React.ReactNode }) {
return <small className="text-xs text-muted">{children}</small>;
}
type Tr = (french: string, english: string) => string;
function stopPolicies(tr: Tr): { value: StopPolicy; label: string }[] {
return [
{ value: "stopOnAppExit", label: tr("Arrêter à la fermeture de l’application", "Stop on app exit") },
{ value: "keepAlive", label: tr("Garder actif", "Keep alive") },
{ value: "stopWhenUnused", label: tr("Arrêter si inutilisé", "Stop when unused") },
];
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
const units = ["B", "KB", "MB", "GB", "TB"] as const;
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
const digits = unit === 0 || value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
function deleteArtifactConfirmation(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
tr: Tr,
): string {
const size =
artifact.sizeBytes == null ? "" : `${tr("Espace libéré", "Space freed")}: ${formatBytes(artifact.sizeBytes)}.`;
return [
tr("Supprimer le modèle téléchargé ?", "Delete downloaded model?"),
`${tr("Serveur", "Server")}: ${server.name}`,
tr("Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.", "The local server will remain configured, but IdeA will have to download this model again on the next launch."),
size,
]
.filter(Boolean)
.join("\n");
}
export interface ModelServersPanelProps {
/** The model-server registry view-model (from `useModelServers`). */
vm: ModelServersViewModel;
}
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
const { tr } = useI18n();
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
// no editor open (list-only).
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
function startAdd() {
vm.clearError();
setDraft(emptyModelServer());
}
function startEdit(server: LocalModelServerConfig) {
vm.clearError();
setDraft({ ...server });
}
async function submit(nextDraft?: LocalModelServerConfig) {
const submitted = nextDraft ?? draft;
if (!submitted) return;
const saved = await vm.save(submitted);
if (saved) setDraft(null);
}
async function confirmDeleteArtifact(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
) {
if (!window.confirm(deleteArtifactConfirmation(server, artifact, tr))) return;
await vm.deleteArtifact(server.id);
}
return (
<Panel
aria-label={tr("Serveurs de modèles locaux", "Local model servers")}
title={
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-content">
{tr("Serveurs de modèles locaux", "Local model servers")}
</h3>
<Button
size="sm"
aria-label={tr("Ajouter un serveur de modèles", "Add model server")}
onClick={startAdd}
className="ml-auto"
>
{tr("Ajouter un serveur", "Add server")}
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
{vm.error && (
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
)}
{vm.notice && (
<p role="status" className="text-sm text-muted">
{vm.notice}
</p>
)}
{vm.servers.length === 0 && !draft && (
<p className="text-xs text-muted">
{tr("Aucun serveur local déclaré. Ajoutez-en un pour gérer automatiquement un `llama-server`, ou continuez à utiliser un point d’accès externe.", "No local server declared yet. Add one to auto-manage a `llama-server`, or keep using an external endpoint.")}
</p>
)}
<ul className="flex list-none flex-col gap-2 p-0">
{vm.servers.map((server) => {
const artifact = server.artifact;
const downloaded =
artifact?.state === "downloaded" ? artifact : undefined;
const deletingArtifact = vm.deletingArtifactId === server.id;
const downloadingArtifact = artifact?.state === "downloading";
return (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? ` · ${tr("démarrage automatique", "auto-start")}` : ""}
</span>
</span>
<span className="ml-auto flex items-center gap-1">
{downloadingArtifact && (
<Button
size="sm"
variant="secondary"
aria-label={tr(`Téléchargement en cours pour ${server.name}`, `Download in progress for ${server.name}`)}
disabled
>
{tr("Téléchargement en cours", "Downloading")}
</Button>
)}
{downloaded && (
<Button
size="sm"
variant="danger"
aria-label={tr(`Supprimer le modèle téléchargé de ${server.name}`, `Delete downloaded model for ${server.name}`)}
onClick={() => void confirmDeleteArtifact(server, downloaded)}
loading={deletingArtifact}
disabled={vm.busy && !deletingArtifact}
>
{deletingArtifact ? tr("Suppression…", "Deleting…") : tr("Supprimer le modèle téléchargé", "Delete downloaded model")}
</Button>
)}
<Button
size="sm"
aria-label={tr(`Modifier ${server.name}`, `Edit ${server.name}`)}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
{tr("Modifier", "Edit")}
</Button>
<IconButton
size="sm"
aria-label={tr(`Supprimer ${server.name}`, `Delete ${server.name}`)}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
);
})}
</ul>
{draft && (
<ServerEditor
draft={draft}
busy={vm.busy}
preview={vm.preview}
onChange={setDraft}
onCancel={() => {
vm.clearError();
setDraft(null);
}}
onSubmit={(nextDraft) => void submit(nextDraft)}
/>
)}
</div>
</Panel>
);
}
/** Segmented control choosing the model source (no jargon in the labels). */
function SourceTabs({
kind,
onSelect,
}: {
kind: ModelSourceKind;
onSelect: (kind: ModelSourceKind) => void;
}) {
const { tr } = useI18n();
const tab = (value: ModelSourceKind, label: string) => {
const active = kind === value;
return (
<button
type="button"
aria-pressed={active}
onClick={() => onSelect(value)}
className={cn(
"h-8 flex-1 rounded-md px-3 text-xs font-medium transition-colors",
active
? "bg-primary text-white"
: "bg-raised text-muted hover:text-content",
)}
>
{label}
</button>
);
};
return (
<div
role="group"
aria-label={tr("Source du modèle", "Model source")}
className="flex gap-1 rounded-md border border-border p-1"
>
{tab("huggingFace", tr("Télécharger depuis Hugging Face", "Download from Hugging Face"))}
{tab("localPath", tr("Fichier .gguf local", "Local .gguf file"))}
</div>
);
}
/** The read-only, backend-built command preview block. */
function CommandPreview({
preview,
autoStart,
}: {
preview: ModelServerCommandPreview | null;
autoStart: boolean;
}) {
const { tr } = useI18n();
const [copied, setCopied] = useState(false);
const title = autoStart ? tr("Commande générée", "Generated command") : tr("Commande à lancer manuellement", "Command to run manually");
async function copy() {
if (!preview) return;
try {
await navigator.clipboard.writeText(preview.display);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard denied — nothing actionable, the text stays selectable.
}
}
return (
<section
aria-label={tr("Aperçu de la commande", "Command preview")}
className="flex flex-col gap-1 rounded-md border border-border bg-raised p-2"
>
<div className="flex items-center gap-2">
<Caption>{title}</Caption>
<Button
size="sm"
variant="ghost"
aria-label={tr("Copier la commande", "Copy command")}
onClick={() => void copy()}
disabled={!preview}
className="ml-auto"
>
{copied ? tr("Copié", "Copied") : tr("Copier", "Copy")}
</Button>
</div>
{preview ? (
<code
aria-label={tr("Ligne de commande prévisualisée", "Command line preview")}
className="whitespace-pre-wrap break-all font-mono text-xs text-content"
>
{preview.display}
</code>
) : (
<span className="text-xs text-muted">
{tr("Renseignez une source de modèle pour voir la commande.", "Enter a model source to see the command.")}
</span>
)}
</section>
);
}
/** The add/edit wizard for one server draft. */
function ServerEditor({
draft,
busy,
preview,
onChange,
onCancel,
onSubmit,
}: {
draft: LocalModelServerConfig;
busy: boolean;
preview: (
config: LocalModelServerConfig,
) => Promise<ModelServerCommandPreview | null>;
onChange: (next: LocalModelServerConfig) => void;
onCancel: () => void;
onSubmit: (nextDraft?: LocalModelServerConfig) => void;
}) {
const { tr } = useI18n();
const errors: ModelServerErrors = validateModelServer(draft);
const valid = Object.keys(errors).length === 0;
const patch = (next: Partial<LocalModelServerConfig>) =>
onChange({ ...draft, ...next });
const canonicalArgsText = draft.args.join(" ");
const [argsText, setArgsText] = useState(canonicalArgsText);
useEffect(() => {
setArgsText(canonicalArgsText);
}, [canonicalArgsText, draft.id]);
function draftWithCommittedArgs(): LocalModelServerConfig {
return { ...draft, args: parseArgs(argsText) };
}
function commitArgs() {
const next = draftWithCommittedArgs();
onChange(next);
}
// Sticky source tab: keep the chosen kind even when the field is momentarily
// blank (an empty field clears `modelSource`, which must not snap the tab).
const [sourceKind, setSourceKind] = useState<ModelSourceKind>(
draft.modelSource?.type ?? "huggingFace",
);
const hfRepo =
draft.modelSource?.type === "huggingFace" ? draft.modelSource.repo : "";
const localPath =
draft.modelSource?.type === "localPath" ? draft.modelSource.path : "";
// Apply a new source and pre-fill the served name if the user hasn't set one.
function setSource(source: ModelSource | undefined) {
const next: Partial<LocalModelServerConfig> = { modelSource: source };
if (draft.servedModelName.trim().length === 0) {
const derived = deriveServedModelName(source);
if (derived.length > 0) next.servedModelName = derived;
}
patch(next);
}
function selectKind(kind: ModelSourceKind) {
if (kind === sourceKind) return;
setSourceKind(kind);
// Switching tabs starts the new source empty (⇒ cleared) rather than
// carrying the previous value into an incompatible field.
patch({ modelSource: undefined });
}
// Debounced backend preview. The backend is the sole authority on the argv.
const [cmd, setCmd] = useState<ModelServerCommandPreview | null>(null);
const draftKey = JSON.stringify({
modelSource: draft.modelSource,
port: draft.port,
host: draft.host,
gpuLayers: draft.gpuLayers,
contextSize: draft.contextSize,
jinja: draft.jinja,
binaryPath: draft.binaryPath,
args: draft.args,
});
const latest = useRef(draft);
latest.current = draft;
useEffect(() => {
if (!draft.modelSource) {
setCmd(null);
return;
}
let cancelled = false;
const handle = setTimeout(() => {
void preview(latest.current).then((result) => {
if (!cancelled) setCmd(result);
});
}, 250);
return () => {
cancelled = true;
clearTimeout(handle);
};
// `draftKey` captures the argv-affecting fields; `preview` is stable.
}, [draftKey, preview]); // eslint-disable-line react-hooks/exhaustive-deps
const reservedHits = reservedFlagsIn(parseArgs(argsText));
return (
<fieldset
aria-label={tr("Éditeur de serveur de modèles", "Model server editor")}
className="flex flex-col gap-3 rounded-md border border-primary/40 p-3"
>
<legend className="px-1 text-xs font-medium text-muted">
{draft.name.trim().length > 0 ? draft.name : tr("Nouveau serveur", "New server")}
</legend>
<label className="flex flex-col gap-1">
<Caption>{tr("Nom", "Name")}</Caption>
<Input
aria-label={tr("Nom du serveur", "Server name")}
value={draft.name}
invalid={Boolean(errors.name)}
onChange={(e) => patch({ name: e.target.value })}
/>
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
</label>
{/* --- Source du modèle --------------------------------------------- */}
<div className="flex flex-col gap-1">
<Caption>{tr("Source du modèle", "Model source")}</Caption>
<SourceTabs kind={sourceKind} onSelect={selectKind} />
</div>
{sourceKind === "huggingFace" ? (
<label className="flex flex-col gap-1">
<Caption>{tr("Modèle Hugging Face", "Hugging Face model")}</Caption>
<Input
aria-label={tr("Modèle Hugging Face", "Hugging Face model")}
value={hfRepo}
placeholder="unsloth/Qwen3.5-9B-GGUF:Q4_K_M"
invalid={Boolean(errors.modelSource)}
onChange={(e) => {
const v = e.target.value;
setSource(
v.trim().length === 0
? undefined
: { type: "huggingFace", repo: v },
);
}}
/>
<Hint>{tr("llama.cpp téléchargera et mettra le fichier en cache automatiquement.", "llama.cpp will download and cache the file automatically.")}</Hint>
{errors.modelSource && (
<small className="text-xs text-danger">{errors.modelSource}</small>
)}
</label>
) : (
<label className="flex flex-col gap-1">
<Caption>{tr("Fichier .gguf", ".gguf file")}</Caption>
<Input
aria-label={tr("Fichier .gguf local", "Local .gguf file")}
value={localPath}
placeholder="/home/me/models/qwen.gguf"
invalid={Boolean(errors.modelSource)}
onChange={(e) => {
const v = e.target.value;
setSource(
v.trim().length === 0
? undefined
: { type: "localPath", path: v },
);
}}
/>
<Hint>{tr("Chemin absolu vers un fichier .gguf déjà présent.", "Absolute path to an existing .gguf file.")}</Hint>
{errors.modelSource && (
<small className="text-xs text-danger">{errors.modelSource}</small>
)}
</label>
)}
{/* --- Nom du modèle dans IdeA -------------------------------------- */}
<label className="flex flex-col gap-1">
<Caption>{tr("Nom du modèle dans IdeA", "Model name in IdeA")}</Caption>
<Input
aria-label={tr("Nom du modèle servi", "Served model name")}
value={draft.servedModelName}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.servedModelName)}
onChange={(e) => patch({ servedModelName: e.target.value })}
/>
<Hint>{tr("Nom envoyé aux clients compatibles OpenAI.", "Name sent to OpenAI-compatible clients.")}</Hint>
{errors.servedModelName && (
<small className="text-xs text-danger">{errors.servedModelName}</small>
)}
</label>
{/* --- Serveur ------------------------------------------------------ */}
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
<Caption>{tr("Commande du serveur", "Server command")}</Caption>
<Input
aria-label={tr("Chemin de l’exécutable du serveur", "Server binary path")}
value={draft.binaryPath ?? ""}
placeholder="llama-server"
onChange={(e) => {
const v = e.target.value;
patch({ binaryPath: v.length === 0 ? undefined : v });
}}
/>
<Hint>{tr("Laissez vide pour trouver `llama-server` via le PATH.", "Leave empty to resolve `llama-server` through PATH.")}</Hint>
</label>
<label className="flex min-w-[6rem] flex-col gap-1">
<Caption>{tr("Port", "Port")}</Caption>
<Input
aria-label={tr("Port du serveur", "Server port")}
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
inputMode="numeric"
invalid={Boolean(errors.port)}
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
/>
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>{tr("URL de base (compatible OpenAI)", "Base URL (OpenAI-compatible)")}</Caption>
<Input
aria-label={tr("URL de base du serveur", "Server base URL")}
value={draft.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
onChange={(e) => patch({ baseURL: e.target.value })}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex items-start gap-2">
<input
type="checkbox"
aria-label={tr("Démarrage automatique du serveur", "Server auto start")}
checked={draft.autoStart}
disabled={!draft.modelSource}
onChange={(e) => patch({ autoStart: e.target.checked })}
className="mt-1 accent-primary disabled:opacity-50"
/>
<span className="flex flex-col">
<Caption>{tr("Démarrer automatiquement", "Start automatically")}</Caption>
{!draft.modelSource && (
<Hint>{tr("Renseignez d’abord une source de modèle pour l’activer.", "Enter a model source first to enable it.")}</Hint>
)}
</span>
</label>
{/* --- Réglages llama.cpp (avancé) --------------------------------- */}
<details className="rounded-md border border-border p-2">
<summary className="cursor-pointer text-xs font-medium text-muted">
{tr("Réglages llama.cpp", "llama.cpp settings")}
</summary>
<div className="mt-2 flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>
{tr("Couches GPU", "GPU layers")} <span className="text-muted">(-ngl)</span>
</Caption>
<Input
aria-label={tr("Couches GPU", "GPU layers")}
value={draft.gpuLayers === undefined ? "" : String(draft.gpuLayers)}
inputMode="numeric"
placeholder="99"
onChange={(e) => {
const v = e.target.value.trim();
patch({
gpuLayers: v.length === 0 ? undefined : Number.parseInt(v, 10),
});
}}
/>
<Hint>{tr("0 = processeur uniquement.", "0 = CPU only.")}</Hint>
</label>
<label className="flex min-w-[8rem] flex-1 flex-col gap-1">
<Caption>
{tr("Taille du contexte", "Context size")} <span className="text-muted">(-c)</span>
</Caption>
<Input
aria-label={tr("Taille du contexte", "Context size")}
value={
draft.contextSize === undefined ? "" : String(draft.contextSize)
}
inputMode="numeric"
placeholder="16384"
onChange={(e) => {
const v = e.target.value.trim();
patch({
contextSize:
v.length === 0 ? undefined : Number.parseInt(v, 10),
});
}}
/>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
{tr("Adresse d’écoute", "Listen address")} <span className="text-muted">(--host)</span>
</Caption>
<Input
aria-label={tr("Adresse d’écoute", "Listen address")}
value={draft.host}
placeholder="127.0.0.1"
invalid={Boolean(errors.host)}
onChange={(e) => patch({ host: e.target.value })}
/>
{errors.host && (
<small className="text-xs text-danger">{errors.host}</small>
)}
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={tr("Modèle de discussion Jinja", "Jinja chat template")}
checked={draft.jinja}
onChange={(e) => patch({ jinja: e.target.checked })}
className="accent-primary"
/>
<Caption>
{tr("Modèle de discussion Jinja", "Jinja chat template")} <span className="text-muted">(--jinja)</span>
</Caption>
</label>
<label className="flex flex-col gap-1">
<Caption>{tr("Arguments supplémentaires", "Extra arguments")}</Caption>
<Input
aria-label={tr("Arguments supplémentaires", "Extra arguments")}
value={argsText}
placeholder="--flash-attn --parallel 2"
onChange={(e) => setArgsText(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
onBlur={commitArgs}
/>
{reservedHits.length > 0 && (
<small className="text-xs text-warning" role="status">
{reservedHits.join(", ")} : {tr("cette option est déjà pilotée par un champ ci-dessus.", "this option is already controlled by a field above.")}
</small>
)}
</label>
<label className="flex items-center gap-2">
<Caption>{tr("Politique d’arrêt", "Stop policy")}</Caption>
<select
aria-label={tr("Politique d’arrêt du serveur", "Server stop policy")}
value={draft.stopPolicy}
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
className={cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none focus:border-primary",
)}
>
{stopPolicies(tr).map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</label>
</div>
</details>
<CommandPreview preview={cmd} autoStart={draft.autoStart} />
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
aria-label={tr("Enregistrer le serveur de modèles", "Save model server")}
onClick={() => onSubmit(draftWithCommittedArgs())}
disabled={busy || !valid}
>
{tr("Enregistrer le serveur", "Save server")}
</Button>
<Button
variant="ghost"
size="sm"
aria-label={tr("Annuler la modification du serveur", "Cancel model server")}
onClick={onCancel}
disabled={busy}
>
{tr("Annuler", "Cancel")}
</Button>
</div>
</fieldset>
);
}