feat(model-server): progression fine du téléchargement du modèle llamacpp (#54)

Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1).

Backend : le port de téléchargement HF publie une progression débouncée
(bytes reçus / total, pourcentage) via le stream de statut du serveur
modèle, avec gestion du total inconnu (pas de faux %), du cache hit,
de l'annulation et du timeout.

Frontend : l'overlay plein-cellule de préparation du serveur affiche la
progression réelle (barre, %, octets, source) en mappant le fil de
statut, avec la règle « pas de faux % » quand le total est inconnu.

Tests : application + infrastructure (téléchargement débouncé, cancel,
timeout, cache hit, total inconnu) et vitest (overlay + formatage pur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:37:14 +02:00
parent bd335c3a1c
commit 2183dfd291
15 changed files with 1368 additions and 87 deletions

View File

@ -11,9 +11,14 @@ export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
export {
correlateModelServerStatus,
modelServerOverlayText,
describeModelServerDownload,
formatBytes,
useModelServerLaunchState,
} from "./modelServerLaunch";
export type { ModelServerLaunchState } from "./modelServerLaunch";
export type {
ModelServerLaunchState,
ModelServerDownloadProgress,
} from "./modelServerLaunch";
export { ResumeProjectPanel } from "./ResumeProjectPanel";
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
export { useResumeProject } from "./useResumeProject";

View File

@ -0,0 +1,109 @@
/**
* Ticket #54 F2 — pure download-progress formatting for the model-server overlay.
* These lock the wire→display mapping independently of React: byte formatting,
* the determinate/indeterminate split, and the "no fake %" rule.
*/
import { describe, it, expect } from "vitest";
import type { ModelServerStatus } from "@/domain";
import {
formatBytes,
describeModelServerDownload,
modelServerOverlayText,
} from "./modelServerLaunch";
function downloading(
fields: Partial<{
downloadedBytes: number | null;
totalBytes: number | null;
percent: number | null;
source: string | null;
}> = {},
): ModelServerStatus {
return {
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
...fields,
};
}
describe("formatBytes", () => {
it("shows whole bytes and SI-scaled larger units", () => {
expect(formatBytes(0)).toBe("0 o");
expect(formatBytes(512)).toBe("512 o");
expect(formatBytes(1000)).toBe("1 Ko");
expect(formatBytes(1_500_000)).toBe("1.5 Mo");
expect(formatBytes(4_200_000_000)).toBe("4.2 Go");
});
it("guards non-finite / negative", () => {
expect(formatBytes(-5)).toBe("0 o");
expect(formatBytes(Number.NaN)).toBe("0 o");
});
});
describe("describeModelServerDownload", () => {
it("returns null for every non-downloading state", () => {
expect(describeModelServerDownload(undefined)).toBeNull();
expect(describeModelServerDownload({ state: "probing" })).toBeNull();
expect(describeModelServerDownload({ state: "starting" })).toBeNull();
expect(
describeModelServerDownload({ state: "ready", reused: false }),
).toBeNull();
});
it("exposes percent + a 'X / Y' bytes label when both bounds are known", () => {
const p = describeModelServerDownload(
downloading({
downloadedBytes: 1_500_000,
totalBytes: 3_000_000,
percent: 50,
source: "unsloth/Qwen3-Coder-30B",
}),
);
expect(p).toEqual({
percent: 50,
bytesLabel: "1.5 Mo / 3 Mo",
source: "unsloth/Qwen3-Coder-30B",
});
});
it("clamps percent to 0..100", () => {
expect(describeModelServerDownload(downloading({ percent: 137 }))?.percent).toBe(
100,
);
expect(describeModelServerDownload(downloading({ percent: -3 }))?.percent).toBe(
0,
);
});
it("is indeterminate (percent null) when the total is unknown", () => {
const p = describeModelServerDownload(
downloading({ downloadedBytes: 800_000, totalBytes: null, percent: null }),
);
expect(p).toEqual({ percent: null, bytesLabel: "800 Ko", source: null });
});
it("has no bytes label when nothing about size is known", () => {
expect(describeModelServerDownload(downloading())).toEqual({
percent: null,
bytesLabel: null,
source: null,
});
});
});
describe("modelServerOverlayText (unchanged F1 titles)", () => {
it("keeps the download / loading titles", () => {
expect(modelServerOverlayText(downloading())).toBe(
"Téléchargement du modèle…",
);
expect(modelServerOverlayText({ state: "starting" })).toBe(
"Chargement du serveur…",
);
expect(modelServerOverlayText({ state: "ready", reused: true })).toBeNull();
});
});

View File

@ -37,9 +37,9 @@ export function correlateModelServerStatus(
* - `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.
* This is the single predicate that decides whether the launch overlay covers a
* cell (and supplies its title). The download *progress* (bar/%/bytes, F2) is a
* separate, additive concern — see {@link describeModelServerDownload}.
*/
export function modelServerOverlayText(
status: ModelServerStatus | undefined,
@ -56,6 +56,60 @@ export function modelServerOverlayText(
}
}
/**
* Human-readable byte size, decimal (SI) units so it matches what download UIs
* and llama.cpp report (`1 Mo = 1000 Ko`). Bytes are shown whole; larger units
* keep one decimal, trailing `.0` trimmed. Guards against non-finite/negative
* inputs (→ "0 o").
*/
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 o";
const units = ["o", "Ko", "Mo", "Go", "To"];
let value = n;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
const rounded =
unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded} ${units[unit]}`;
}
/**
* Download progress detail extracted from a `downloading` status (F2), or `null`
* for any other state (`starting`/`probing`/`ready`/`failed` → no bar). Mirrors
* the wire shape without inventing data:
* - `percent`: the backend's completion % clamped to `0..100` when the total is
* known, else `null` → the overlay shows an **indeterminate** bar (no fake %);
* - `bytesLabel`: `"X Mo / Y Mo"` when both bounds are known, `"X Mo"` when only
* the downloaded amount is, else `null`;
* - `source`: the remote model ref (e.g. HF `namespace/repo`) when present.
*/
export interface ModelServerDownloadProgress {
percent: number | null;
bytesLabel: string | null;
source: string | null;
}
export function describeModelServerDownload(
status: ModelServerStatus | undefined,
): ModelServerDownloadProgress | null {
if (!status || status.state !== "downloading") return null;
const { downloadedBytes, totalBytes, percent, source } = status;
const bytesLabel =
downloadedBytes != null && totalBytes != null
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: downloadedBytes != null
? formatBytes(downloadedBytes)
: null;
return {
percent: percent != null ? Math.min(100, Math.max(0, percent)) : null,
bytesLabel,
source: source ?? null,
};
}
/** What {@link useModelServerLaunchState} exposes to a consumer. */
export interface ModelServerLaunchState {
/**

View File

@ -12,7 +12,17 @@
* 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";
import { render, screen, waitFor, configure } from "@testing-library/react";
// Delivery of a `modelServerStatusChanged` event is made deterministic by
// `trackSubscriptions` (no lost event). What remains is *propagation* latency:
// the overlay only shows once the hook's async profile + agent loads resolve, so
// the assertion is a guaranteed-eventual state. Under the full-suite parallel
// run (22 files) the worker's event loop can be starved past the 1000 ms default
// async-util budget — a scheduling delay, not a logic race. A generous timeout
// removes that flake without weakening any assertion (the state is guaranteed to
// arrive; we only wait long enough for it under load).
configure({ asyncUtilTimeout: 5000 });
vi.mock("@xterm/xterm", () => ({
Terminal: class {
@ -108,16 +118,35 @@ function renderGrid(gateways: Gateways) {
);
}
async function emit(
/**
* Emit `status` and KEEP re-emitting it until `check` passes (the emit runs
* inside `waitFor`, whose async wrapper already provides `act`). This kills the
* emit-vs-subscribe race at the root — with no need to identify *which*
* `onDomainEvent` subscriber is the overlay hook's.
*
* Why the previous `waitSubscribed(count > 0)` gate was insufficient: several
* components subscribe to the event stream, and the ones outside `LeafView`
* mount *before* the async layout load brings `LeafView` (and its overlay hook)
* into the tree — so the global subscriber count crosses 0 while the overlay
* hook's own handler is not yet registered, and a single emit gated on it is
* still dropped. Re-emitting is safe because the reducer stores an idempotent
* per-server value: whichever attempt lands *after* the overlay hook subscribed
* and its profile/agent loads resolved is the one that makes `check` pass; the
* earlier (possibly dropped) attempts are no-ops. Used for BOTH appearance and
* retraction assertions so no case can regress into the race.
*/
async function emitUntil(
systemGateway: MockSystemGateway,
status: ModelServerStatus,
check: () => void,
): Promise<void> {
await act(async () => {
await waitFor(() => {
systemGateway.emit({
type: "modelServerStatusChanged",
serverId: SERVER_ID,
status,
});
check();
});
}
@ -147,29 +176,28 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// No overlay before any status.
// The cell exists but no status has been emitted yet → no overlay.
await screen.findByTestId("layout-leaf");
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",
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
},
() =>
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(() =>
await emitUntil(systemGateway, { state: "ready", reused: false }, () =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
@ -194,27 +222,25 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await emit(systemGateway, { state: "probing" });
await waitFor(() =>
await emitUntil(systemGateway, { state: "probing" }, () =>
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 emitUntil(systemGateway, { state: "starting" }, () =>
expect(
screen.getByTestId("model-server-overlay").textContent,
).toContain("Chargement du serveur"),
);
// No progressbar for a non-download preparing state.
expect(screen.queryByTestId("model-server-progress")).toBeNull();
await emit(systemGateway, {
state: "failed",
code: "spawn",
message: "boom",
});
await waitFor(() =>
expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
await emitUntil(
systemGateway,
{ state: "failed", code: "spawn", message: "boom" },
() => expect(screen.queryByTestId("model-server-overlay")).toBeNull(),
);
});
@ -255,25 +281,156 @@ describe("LayoutGrid — model-server launch overlay (ticket #54)", () => {
});
renderGrid(gateways);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf").length).toBe(2),
// Both cells carry the overlay from a single server event (re-emitted until
// both cells' overlay hooks have subscribed and correlated the server).
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: null,
totalBytes: null,
percent: null,
source: null,
},
() =>
expect(screen.getAllByTestId("model-server-overlay").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(() =>
await emitUntil(systemGateway, { state: "ready", reused: true }, () =>
expect(screen.queryAllByTestId("model-server-overlay").length).toBe(0),
);
});
});
/** Render a single pinned-agent cell and return its system gateway. */
async function renderSinglePinnedCell(): Promise<MockSystemGateway> {
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);
// No subscription gate needed: every emission goes through `emitUntil`, which
// re-emits until the overlay hook has subscribed and correlated the server.
return systemGateway;
}
describe("LayoutGrid — download progress (ticket #54 F2)", () => {
it("known percent → determinate progressbar + % + formatted bytes + source", async () => {
const systemGateway = await renderSinglePinnedCell();
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: 1_500_000,
totalBytes: 3_000_000,
percent: 50,
source: "unsloth/Qwen3-Coder-30B",
},
() =>
expect(
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
).toBe("50"),
);
const bar = screen.getByTestId("model-server-progress");
expect(bar.getAttribute("role")).toBe("progressbar");
expect(bar.getAttribute("aria-valuemin")).toBe("0");
expect(bar.getAttribute("aria-valuemax")).toBe("100");
const label = screen.getByTestId("model-server-progress-label");
expect(label.textContent).toContain("50 %");
expect(label.textContent).toContain("1.5 Mo / 3 Mo");
expect(screen.getByTestId("model-server-source").textContent).toBe(
"unsloth/Qwen3-Coder-30B",
);
});
it("unknown total → indeterminate bar, NO fake %, shows downloaded bytes", async () => {
const systemGateway = await renderSinglePinnedCell();
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: 800_000,
totalBytes: null,
percent: null,
source: null,
},
() => expect(screen.getByTestId("model-server-progress")).toBeTruthy(),
);
const bar = screen.getByTestId("model-server-progress");
// Indeterminate: no aria-valuenow set.
expect(bar.hasAttribute("aria-valuenow")).toBe(false);
const label = screen.getByTestId("model-server-progress-label");
expect(label.textContent).not.toContain("%");
expect(label.textContent).toContain("800 Ko");
// No source line when the backend sends none.
expect(screen.queryByTestId("model-server-source")).toBeNull();
});
it("starting/probing show the title but NO progressbar", async () => {
const systemGateway = await renderSinglePinnedCell();
await emitUntil(systemGateway, { state: "starting" }, () =>
expect(
screen.getByTestId("model-server-overlay").textContent,
).toContain("Chargement du serveur"),
);
expect(screen.queryByTestId("model-server-progress")).toBeNull();
});
it("later progress events update the bar in place", async () => {
const systemGateway = await renderSinglePinnedCell();
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: 1_000_000,
totalBytes: 4_000_000,
percent: 25,
source: null,
},
() =>
expect(
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
).toBe("25"),
);
await emitUntil(
systemGateway,
{
state: "downloading",
downloadedBytes: 3_000_000,
totalBytes: 4_000_000,
percent: 75,
source: null,
},
() =>
expect(
screen.getByTestId("model-server-progress").getAttribute("aria-valuenow"),
).toBe("75"),
);
expect(
screen.getByTestId("model-server-progress-label").textContent,
).toContain("3 Mo / 4 Mo");
});
});

View File

@ -40,6 +40,7 @@ import {
} from "@/features/announcements";
import {
modelServerOverlayText,
describeModelServerDownload,
useModelServerLaunchState,
} from "@/features/agents";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
@ -382,7 +383,12 @@ function LeafView({
const pinnedAgent = agentId
? agents.find((a) => a.id === agentId)
: undefined;
const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent));
const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
// for every non-`downloading` state, so `starting`/`probing` show just the
// title. An indeterminate download (unknown total) yields `percent: null`.
const modelServerDownload = describeModelServerDownload(modelServerStatus);
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
@ -976,16 +982,101 @@ function LeafView({
userSelect: "none",
}}
>
<span
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
maxWidth: "80%",
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
padding: "8px 14px",
}}
>
{modelServerOverlay}
</span>
<span>{modelServerOverlay}</span>
{modelServerDownload && (
<>
{/* Determinate bar when the total is known; an indeterminate
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
<div
data-testid="model-server-progress"
role="progressbar"
aria-label="progression du téléchargement"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
modelServerDownload.percent != null
? Math.round(modelServerDownload.percent)
: undefined
}
style={{
position: "relative",
width: 220,
maxWidth: "100%",
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "var(--color-raised, #2a2a2a)",
}}
>
<div
style={
modelServerDownload.percent != null
? {
height: "100%",
width: `${modelServerDownload.percent}%`,
background: "var(--color-primary, #5b9bd5)",
transition: "width 120ms linear",
}
: {
// Indeterminate: a sliding sliver.
position: "absolute",
top: 0,
bottom: 0,
width: "40%",
background: "var(--color-primary, #5b9bd5)",
animation:
"model-server-indeterminate 1.1s ease-in-out infinite",
}
}
/>
</div>
{(modelServerDownload.percent != null ||
modelServerDownload.bytesLabel) && (
<span
data-testid="model-server-progress-label"
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
>
{modelServerDownload.percent != null
? `${Math.round(modelServerDownload.percent)} %`
: ""}
{modelServerDownload.percent != null &&
modelServerDownload.bytesLabel
? " · "
: ""}
{modelServerDownload.bytesLabel ?? ""}
</span>
)}
{modelServerDownload.source && (
<span
data-testid="model-server-source"
style={{
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={modelServerDownload.source}
>
{modelServerDownload.source}
</span>
)}
</>
)}
</div>
</div>
)}
</div>

View File

@ -74,3 +74,14 @@
outline-offset: 1px;
}
}
/* Indeterminate progress sliver (ticket #54 F2): a model download whose total
size is unknown slides a sliver back and forth instead of showing a fake %. */
@keyframes model-server-indeterminate {
0% {
left: -40%;
}
100% {
left: 100%;
}
}