feat(model-server): afficher le téléchargement du modèle llamacpp au démarrage (#54)

Ajoute un handle du téléchargement des modèles lors du démarrage de
llamacpp : le domaine et l'application émettent la progression de
téléchargement du modèle, relayée en événement côté app-tauri, et l'UI
l'affiche via un badge de lancement et un overlay de cellule pendant que
le serveur de modèle démarre.

Backend (B1) : progression de téléchargement dans domain/application,
relais d'événement app-tauri, couverture de tests.
Frontend (F1) : modelServerLaunch, badge et overlay LayoutGrid, tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:14:05 +02:00
parent c67ec4f7bd
commit fe7ed0aa20
12 changed files with 891 additions and 29 deletions

View File

@ -22,6 +22,7 @@ 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";
@ -335,11 +336,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
// 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 modelServerStatus = correlateModelServerStatus(
a,
vm.profiles,
vm.modelServerStatusByServer,
);
const launchFailure = vm.launchFailureByAgent[a.id];
return (
<li

View File

@ -39,6 +39,8 @@ function describeStatus(
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 "downloading":
return { label: "téléchargement du modèle…", 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" }

View File

@ -8,6 +8,12 @@ export { useAgents } from "./useAgents";
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
export {
correlateModelServerStatus,
modelServerOverlayText,
useModelServerLaunchState,
} from "./modelServerLaunch";
export type { ModelServerLaunchState } from "./modelServerLaunch";
export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";

View File

@ -0,0 +1,141 @@
/**
* Shared correlation of an agent to its local model server's launch state
* (F35 / ticket #54). One place owns the chain
* `agentId → profileId → opencode.localModelServerId → statusByServer[serverId]`
* so both the {@link AgentsPanel} badge and the full-cell overlay in
* `LayoutGrid`/`LeafView` read the same truth instead of duplicating it.
*
* Pure data + a thin `useGateways`-backed hook: no Tauri names here (the event
* stream and profile list come through the gateways/adapters).
*/
import { useCallback, useEffect, useState } from "react";
import type { Agent, AgentProfile, ModelServerStatus } from "@/domain";
import { useGateways } from "@/app/di";
/**
* Correlate a pinned agent to its model server's current lifecycle status, if
* any. Returns `undefined` when the agent's profile binds no local model server
* or when that server has emitted no status yet.
*/
export function correlateModelServerStatus(
agent: Pick<Agent, "profileId"> | undefined,
profiles: AgentProfile[],
statusByServer: Record<string, ModelServerStatus>,
): ModelServerStatus | undefined {
if (!agent) return undefined;
const serverId = profiles.find((p) => p.id === agent.profileId)?.opencode
?.localModelServerId;
return serverId ? statusByServer[serverId] : undefined;
}
/**
* The full-cell overlay text for a model server that is still preparing, or
* `null` when no overlay should show. Only the "not yet usable" states raise a
* veil — `ready`/`failed`/`notConfigured` (and an absent status) return `null`:
* - `downloading` → "Téléchargement du modèle…",
* - `starting`/`probing` → "Chargement du serveur…".
*
* F1 MVP ignores the progress fields (bytes/percent) — the bar/% is the F2
* stretch. This is the single predicate that decides whether the launch overlay
* covers a cell.
*/
export function modelServerOverlayText(
status: ModelServerStatus | undefined,
): string | null {
if (!status) return null;
switch (status.state) {
case "downloading":
return "Téléchargement du modèle…";
case "starting":
case "probing":
return "Chargement du serveur…";
default:
return null;
}
}
/** What {@link useModelServerLaunchState} exposes to a consumer. */
export interface ModelServerLaunchState {
/**
* The correlated model-server status for a pinned agent (or `undefined` when
* it has no bound server / no status yet). Stable across renders unless the
* profiles or the per-server status map change.
*/
statusForAgent: (
agent: Pick<Agent, "profileId"> | undefined,
) => ModelServerStatus | undefined;
}
/**
* Self-contained correlation source for consumers that do NOT already hold the
* `useAgents` view-model (e.g. `LeafView`). Loads the profiles once and folds
* every `modelServerStatusChanged` event into a per-server status map, exactly
* like `useAgents` does — reusing {@link correlateModelServerStatus} for the
* lookup. A profile change (which may rebind the server) re-pulls the profiles.
*
* `profile`/`system` may be absent in unit tests that inject a partial gateway
* set; both are guarded, in which case the status is simply never found.
*/
export function useModelServerLaunchState(
_projectId: string,
): ModelServerLaunchState {
const { profile, system } = useGateways();
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
const [statusByServer, setStatusByServer] = useState<
Record<string, ModelServerStatus>
>({});
// Load (and reload) the profiles: the `profileId → localModelServerId`
// binding lives on the profile, so we need the current list to correlate.
const reloadProfiles = useCallback(() => {
if (!profile) return;
profile
.listProfiles()
.then(setProfiles)
.catch(() => {
/* ignore — no binding resolvable, overlay stays off */
});
}, [profile]);
useEffect(() => {
reloadProfiles();
}, [reloadProfiles]);
// Fold the model-server lifecycle stream, keyed by server id — the same
// reduction `useAgents` performs. A profile change may rebind the server, so
// re-pull the profiles too.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "modelServerStatusChanged") {
setStatusByServer((prev) => ({
...prev,
[event.serverId]: event.status,
}));
} else if (event.type === "agentProfileChanged") {
reloadProfiles();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system, reloadProfiles]);
const statusForAgent = useCallback(
(agent: Pick<Agent, "profileId"> | undefined) =>
correlateModelServerStatus(agent, profiles, statusByServer),
[profiles, statusByServer],
);
return { statusForAgent };
}

View File

@ -0,0 +1,279 @@
/**
* Ticket #54 F1 — model-server download overlay on agent cells.
*
* When a pinned agent's OpenCode profile binds a local model server and that
* server emits `downloading` / `starting` / `probing` (via the existing
* `modelServerStatusChanged` stream), a full-cell overlay covers the cell — so
* the boot no longer looks like a hung/timed-out empty terminal. The overlay
* retracts at `ready`/`failed`.
*
* Correlation is `agentId → profileId → opencode.localModelServerId → serverId`:
* two cells whose agents share the same bound server are BOTH veiled by a single
* event on that server. xterm is stubbed so the terminal mounts under jsdom.
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { AgentProfile, ModelServerStatus } from "@/domain";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockInputGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
const SERVER_ID = "srv-local-1";
/** A profile that binds the given local model server through OpenCode. */
function localModelProfile(id: string): AgentProfile {
return {
id,
name: "OpenCode + llama.cpp",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencode: {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
localModelServerId: SERVER_ID,
},
};
}
function makeGateways(
agentGateway: MockAgentGateway,
profileGateway: MockProfileGateway,
systemGateway: MockSystemGateway,
): Gateways {
return {
layout: new MockLayoutGateway(),
agent: agentGateway,
profile: profileGateway,
terminal: new MockTerminalGateway(),
system: systemGateway,
input: new MockInputGateway(),
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
async function emit(
systemGateway: MockSystemGateway,
status: ModelServerStatus,
): Promise<void> {
await act(async () => {
systemGateway.emit({
type: "modelServerStatusChanged",
serverId: SERVER_ID,
status,
});
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
it("veils the cell while downloading, then retracts at ready", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
// Pin the agent onto the single default cell.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// No overlay before any status.
expect(screen.queryByTestId("model-server-overlay")).toBeNull();
// Downloading → overlay with the download wording.
await emit(systemGateway, {
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
});
await waitFor(() =>
expect(screen.getByTestId("model-server-overlay")).toBeTruthy(),
);
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
"Téléchargement du modèle",
);
// Ready → overlay gone.
await emit(systemGateway, { state: "ready", reused: false });
await waitFor(() =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
it("shows 'Chargement du serveur…' for starting/probing and hides at failed", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await emit(systemGateway, { state: "probing" });
await waitFor(() =>
expect(
screen.getByTestId("model-server-overlay").textContent,
).toContain("Chargement du serveur"),
);
await emit(systemGateway, { state: "starting" });
expect(screen.getByTestId("model-server-overlay").textContent).toContain(
"Chargement du serveur",
);
await emit(systemGateway, {
state: "failed",
code: "spawn",
message: "boom",
});
await waitFor(() =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
it("veils EVERY cell whose agent references the downloading server", async () => {
const profileGateway = new MockProfileGateway();
await profileGateway.saveProfile(localModelProfile("opencode-local"));
const agentGateway = new MockAgentGateway();
const agentA = await agentGateway.createAgent("p1", {
name: "A",
profileId: "opencode-local",
});
const agentB = await agentGateway.createAgent("p1", {
name: "B",
profileId: "opencode-local",
});
const systemGateway = new MockSystemGateway();
const gateways = makeGateways(agentGateway, profileGateway, systemGateway);
// Split the root into two cells, pin one agent onto each.
const layout = gateways.layout as MockLayoutGateway;
const rootLeaf = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "split",
target: rootLeaf,
direction: "row",
newLeaf: "cell-b",
container: "split-c",
});
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: rootLeaf,
agent: agentA.id,
});
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: "cell-b",
agent: agentB.id,
});
renderGrid(gateways);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf").length).toBe(2),
);
await emit(systemGateway, {
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
});
// Both cells carry the overlay from a single server event.
await waitFor(() =>
expect(screen.getAllByTestId("model-server-overlay").length).toBe(2),
);
await emit(systemGateway, { state: "ready", reused: true });
await waitFor(() =>
expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
);
});
});

View File

@ -38,6 +38,10 @@ import {
TargetAnnouncementsOverlay,
useTargetAnnouncements,
} from "@/features/announcements";
import {
modelServerOverlayText,
useModelServerLaunchState,
} from "@/features/agents";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
@ -367,6 +371,18 @@ function LeafView({
// write-portal veil can be mutually excluded with the F3 overlay (exactly one
// full-cell veil at a time, F3 first). Same source the overlay itself reads.
const { active: busyActive } = useTargetAnnouncements(agentId ?? "");
// Ticket #54 — local model-server launch state for this cell's pinned agent.
// Correlated `agentId → profileId → opencode.localModelServerId → status` via
// the shared hook (same reduction as `useAgents`). While the bound server is
// still preparing (downloading a model / starting / probing), a full-cell
// overlay replaces the timeout/error the empty terminal used to show. It has
// the HIGHEST veil priority: nothing else in the cell is usable until the
// server is ready, so it suppresses the write-portal and F3 veils.
const { statusForAgent } = useModelServerLaunchState(projectId);
const pinnedAgent = agentId
? agents.find((a) => a.id === agentId)
: undefined;
const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent));
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
@ -894,7 +910,8 @@ function LeafView({
is being injected into the agent's PTY, a grey veil with a centred
message sits above the terminal. Only ever shown for an agent cell —
and never together with the F3 overlay (exactly one veil, F3 first). */}
{shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
{!modelServerOverlay &&
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
<div
data-testid="write-portal-overlay"
role="status"
@ -931,9 +948,46 @@ function LeafView({
busy state (agentBusyChanged + read-model hydration), never by the raw
PTY — it retracts at idle even when a turn ends without a completion
event. Self-guards on `active` (busy) and renders null otherwise. */}
{agentId != null && (
{!modelServerOverlay && agentId != null && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
)}
{/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
(suppresses the write-portal + F3 veils above): while the pinned
agent's bound local server is downloading its model / starting /
probing, the empty terminal is covered rather than left to time out.
Retracts at `ready`/`failed` (the predicate returns null then). */}
{modelServerOverlay && (
<div
data-testid="model-server-overlay"
role="status"
aria-live="polite"
aria-label="model server launch"
style={{
position: "absolute",
inset: 0,
zIndex: CELL_Z.veil,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0, 0, 0, 0.55)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
pointerEvents: "none",
userSelect: "none",
}}
>
<span
style={{
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
}}
>
{modelServerOverlay}
</span>
</div>
)}
</div>
{busyNotice && (
<div