/** * 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 | undefined, profiles: AgentProfile[], statusByServer: Record, ): 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…". * * 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, ): 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; } } /** * 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 { /** * 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 | 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([]); const [statusByServer, setStatusByServer] = useState< Record >({}); // 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 | undefined) => correlateModelServerStatus(agent, profiles, statusByServer), [profiles, statusByServer], ); return { statusForAgent }; }