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:
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user