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

@ -23,6 +23,7 @@ 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. */
@ -331,6 +332,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
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}
@ -388,6 +398,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
/>
)}
{/* 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. */}

View File

@ -0,0 +1,100 @@
/**
* F35.1 — presentational tests for {@link ModelServerLaunchBadge}. Pure render
* of the folded launch state: lifecycle labels, the reused/fresh distinction,
* the actionable failure (with a details toggle that hides the code by default),
* and the "render nothing" cases for unmanaged rows.
*/
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
describe("ModelServerLaunchBadge (F35.1)", () => {
it("renders nothing without a status or a failure", () => {
const { container } = render(<ModelServerLaunchBadge />);
expect(container.firstChild).toBeNull();
});
it("renders nothing for a notConfigured status (unmanaged endpoint)", () => {
const { container } = render(
<ModelServerLaunchBadge status={{ state: "notConfigured" }} />,
);
expect(container.firstChild).toBeNull();
});
it("shows the probing and starting lifecycle labels", () => {
const { rerender } = render(
<ModelServerLaunchBadge status={{ state: "probing" }} />,
);
expect(screen.getByLabelText("model server status").textContent).toMatch(
/vérification/i,
);
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
expect(screen.getByLabelText("model server status").textContent).toMatch(
/démarrage/i,
);
});
it("distinguishes a reused server from a freshly started one", () => {
const { rerender } = render(
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
);
expect(screen.getByLabelText("model server status").textContent).toMatch(
/réutilisé/i,
);
rerender(
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
);
const label = screen.getByLabelText("model server status").textContent ?? "";
expect(label).toMatch(/prêt/i);
expect(label).not.toMatch(/réutilisé/i);
});
it("shows an actionable failure message, not a bare 'agent failed'", () => {
render(
<ModelServerLaunchBadge
failure={{
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "llama-server did not become ready on :8080",
}}
/>,
);
const alert = screen.getByRole("alert");
expect(alert.textContent).toMatch(/did not become ready on :8080/);
// The code is not exposed by default (logs/detail stay hidden).
expect(screen.queryByLabelText("launch failure detail")).toBeNull();
});
it("reveals the stable code/cause only when Détails is toggled", () => {
render(
<ModelServerLaunchBadge
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
/>,
);
fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
const detail = screen.getByLabelText("launch failure detail");
expect(detail.textContent).toMatch(/SPAWN/);
expect(detail.textContent).toMatch(/model_server/);
});
it("prefers the agent-scoped failure over a failed status", () => {
render(
<ModelServerLaunchBadge
status={{ state: "failed", code: "S1", message: "status-level" }}
failure={{ cause: "model_server", code: "A1", message: "agent-level" }}
/>,
);
expect(screen.getByRole("alert").textContent).toMatch(/agent-level/);
});
it("falls back to a failed status when there is no agent failure", () => {
render(
<ModelServerLaunchBadge
status={{ state: "failed", code: "S1", message: "status-level boom" }}
/>,
);
expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/);
});
});

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>
);
}

View File

@ -5,7 +5,9 @@
export { AgentsPanel } from "./AgentsPanel";
export type { AgentsPanelProps } from "./AgentsPanel";
export { useAgents } from "./useAgents";
export type { AgentsViewModel } from "./useAgents";
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";

View File

@ -13,6 +13,7 @@ import type {
Agent,
AgentProfile,
GatewayError,
ModelServerStatus,
TerminalSession,
} from "@/domain";
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
@ -41,6 +42,17 @@ export interface AgentLimitState {
suspected?: boolean;
}
/**
* An agent-launch failure surfaced to the UI (F35), from `agentLaunchFailed`.
* `cause` namespaces the origin (e.g. `"model_server"`); `code`/`message` are
* the stable, actionable failure details.
*/
export interface AgentLaunchFailure {
cause: string;
code: string;
message: string;
}
/** What the agents UI needs from this hook. */
export interface AgentsViewModel {
/** All agents known to the project. */
@ -67,6 +79,19 @@ export interface AgentsViewModel {
* absent from the map is unlimited.
*/
limitByAgent: Record<string, AgentLimitState>;
/**
* Local model-server lifecycle status keyed by `serverId` (F35), folded from
* `modelServerStatusChanged`. Correlate an agent to its server through the
* profile's `opencode.localModelServerId`. A server absent from the map has no
* observed status yet.
*/
modelServerStatusByServer: Record<string, ModelServerStatus>;
/**
* Last agent-launch failure keyed by agent id (F35), folded from
* `agentLaunchFailed`. Carries an actionable `message`/`code`/`cause`. Cleared
* for an agent as soon as it launches successfully (`agentLaunched`).
*/
launchFailureByAgent: Record<string, AgentLaunchFailure>;
/** Last error message, or `null`. */
error: string | null;
/** Whether a request is in flight. */
@ -154,6 +179,12 @@ export function useAgents(projectId: string): AgentsViewModel {
const [limitByAgent, setLimitByAgent] = useState<
Record<string, AgentLimitState>
>({});
const [modelServerStatusByServer, setModelServerStatusByServer] = useState<
Record<string, ModelServerStatus>
>({});
const [launchFailureByAgent, setLaunchFailureByAgent] = useState<
Record<string, AgentLaunchFailure>
>({});
const refresh = useCallback(async () => {
setBusy(true);
@ -208,6 +239,15 @@ export function useAgents(projectId: string): AgentsViewModel {
void refresh();
void refreshLiveAgents();
}
// A successful launch supersedes any prior launch failure for that agent
// (F35): drop its stale error banner.
if (event.type === "agentLaunched") {
setLaunchFailureByAgent((prev) => {
if (!(event.agentId in prev)) return prev;
const { [event.agentId]: _cleared, ...rest } = prev;
return rest;
});
}
// Record which door a delegation came through (mcp vs file). Absent
// `source` (older backend) leaves the map untouched → no badge.
if (
@ -269,6 +309,24 @@ export function useAgents(projectId: string): AgentsViewModel {
},
}));
break;
// F35 — local model server lifecycle during launch, keyed by server id.
case "modelServerStatusChanged":
setModelServerStatusByServer((prev) => ({
...prev,
[event.serverId]: event.status,
}));
break;
// F35 — a launch failed with an actionable cause/message, keyed by agent.
case "agentLaunchFailed":
setLaunchFailureByAgent((prev) => ({
...prev,
[event.agentId]: {
cause: event.cause,
code: event.code,
message: event.message,
},
}));
break;
default:
break;
}
@ -475,6 +533,8 @@ export function useAgents(projectId: string): AgentsViewModel {
liveAgents,
delegationSourceByRequester,
limitByAgent,
modelServerStatusByServer,
launchFailureByAgent,
error,
busy,
runningAgentId,

View File

@ -0,0 +1,129 @@
/**
* F35.1 — local model server launch state in {@link useAgents}.
*
* Drives the hook behind the real {@link DIProvider} with an in-memory
* {@link MockSystemGateway} to emit the two F35 domain events, and verifies they
* fold into `modelServerStatusByServer` (keyed by serverId) and
* `launchFailureByAgent` (keyed by agentId).
*
* Cases:
* - `modelServerStatusChanged` → status by server id (probing → starting → ready)
* - `agentLaunchFailed` → actionable failure by agent id
* - `agentLaunched` clears a prior launch failure for that agent
* - a `failed` status is retained per server (independent of the agent map)
*/
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import type { DomainEvent } from "@/domain";
import type { Gateways } from "@/ports";
import { MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useAgents } from "./useAgents";
const PROJECT_ID = "proj-modelserver-001";
const AGENT = "agent-oc";
const SERVER = "srv-42";
function setup() {
const system = new MockSystemGateway();
const agent = {
listAgents: async () => [],
listLiveAgents: async () => [],
};
const profile = { listProfiles: async () => [] };
const gateways = { system, agent, profile } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
return { system, view };
}
async function emit(system: MockSystemGateway, event: DomainEvent) {
await act(async () => {
system.emit(event);
await Promise.resolve();
});
}
describe("useAgents — local model server launch state (F35.1)", () => {
it("folds modelServerStatusChanged into modelServerStatusByServer by serverId", async () => {
const { system, view } = setup();
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "probing" },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "probing",
});
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "starting" },
});
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "ready", reused: true },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "ready",
reused: true,
});
});
it("folds agentLaunchFailed into launchFailureByAgent by agentId", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentLaunchFailed",
agentId: AGENT,
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "llama-server did not become ready on :8080",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toEqual({
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "llama-server did not become ready on :8080",
});
});
it("a successful agentLaunched clears the agent's prior launch failure", async () => {
const { system, view } = setup();
await emit(system, {
type: "agentLaunchFailed",
agentId: AGENT,
cause: "model_server",
code: "SERVER_UNREACHABLE",
message: "boom",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeTruthy();
await emit(system, {
type: "agentLaunched",
agentId: AGENT,
sessionId: "sess-1",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
});
it("keeps a per-server failed status independent of the agent failure map", async () => {
const { system, view } = setup();
await emit(system, {
type: "modelServerStatusChanged",
serverId: SERVER,
status: { state: "failed", code: "SPAWN", message: "binary not found" },
});
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
state: "failed",
code: "SPAWN",
message: "binary not found",
});
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
});
});