/** * 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( , ); } async function emit( systemGateway: MockSystemGateway, status: ModelServerStatus, ): Promise { 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), ); }); });