feat(model-server): frontend modèles locaux — badge de statut & CRUD serveurs (#35) et wizard multi-profils OpenCode (#36)

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>
This commit is contained in:
2026-07-11 15:50:18 +02:00
parent b82ac76f8b
commit 72476a650a
23 changed files with 1893 additions and 15 deletions

View File

@ -0,0 +1,115 @@
/**
* `ModelServerLaunchBadge` — presentational launch-state indicator for an agent
* whose OpenCode profile binds a local model server (F35.1). All state is folded
* in {@link useAgents} (`modelServerStatusByServer` + `launchFailureByAgent`);
* this component only renders it.
*
* It shows, in order of severity:
* - a launch **failure** with its actionable message (not a bare "agent failed")
* — the full logs are never shown by default; a "Détails" toggle reveals the
* stable code/cause only;
* - otherwise the model-server lifecycle: `probing`, `starting`, `ready`
* (distinguishing a reused server from a freshly-started one).
*
* Renders nothing when there is neither a status to show nor a failure, so a
* plain (non-local-model) agent row stays untouched.
*/
import { useState } from "react";
import { cn } from "@/shared";
import type { ModelServerStatus } from "@/domain";
import type { AgentLaunchFailure } from "./useAgents";
export interface ModelServerLaunchBadgeProps {
/** The bound server's lifecycle status, when one has been observed. */
status?: ModelServerStatus;
/** The agent's last launch failure, when one occurred. */
failure?: AgentLaunchFailure;
}
/** Human label + tone for a non-failed lifecycle state. */
function describeStatus(
status: ModelServerStatus,
): { label: string; tone: string; role?: "status" } | null {
switch (status.state) {
case "notConfigured":
return null; // Nothing to surface for an unmanaged endpoint.
case "probing":
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
case "starting":
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
case "ready":
return status.reused
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
: { label: "serveur prêt", tone: "bg-success/20 text-success" };
case "failed":
// Handled by the failure branch below (richer, actionable).
return null;
}
}
export function ModelServerLaunchBadge({
status,
failure,
}: ModelServerLaunchBadgeProps) {
const [showDetail, setShowDetail] = useState(false);
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
// the launch could not complete — prefer the agent-scoped failure (it carries
// the actionable message); fall back to the server `failed` status.
const failed: { code: string; message: string; cause?: string } | null =
failure
? failure
: status?.state === "failed"
? { code: status.code, message: status.message }
: null;
if (failed) {
return (
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span
role="alert"
aria-label="model server launch failed"
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
"bg-danger/20 text-danger",
)}
>
Échec du lancement : {failed.message}
</span>
<button
type="button"
aria-label="toggle launch failure detail"
onClick={() => setShowDetail((v) => !v)}
className="text-xs text-muted underline hover:text-content"
>
Détails
</button>
{showDetail && (
<span aria-label="launch failure detail" className="text-xs text-faint">
code&nbsp;{failed.code}
{failed.cause ? ` · cause ${failed.cause}` : ""}
</span>
)}
</span>
);
}
if (!status) return null;
const described = describeStatus(status);
if (!described) return null;
return (
<span
role={described.role}
aria-label="model server status"
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
described.tone,
)}
>
{described.label}
</span>
);
}