fix: livrable tickets #70 #100 #102 — UX surface scoping

- #70: implémentation suppression modèles locaux téléchargés
- #100: correction scroll OpenCode
- #102: correction fit TUI après switch/layout
- memory note scoping UX
This commit is contained in:
2026-07-26 19:42:06 +02:00
parent 4321d048ac
commit 02603441c1
31 changed files with 1589 additions and 152 deletions

View File

@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
import type {
LocalModelServerConfig,
ModelArtifact,
ModelServerCommandPreview,
ModelSource,
StopPolicy,
@ -47,6 +48,35 @@ const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
{ value: "stopWhenUnused", label: "Stop when unused" },
];
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "";
const units = ["B", "KB", "MB", "GB", "TB"] as const;
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
const digits = unit === 0 || value >= 10 ? 0 : 1;
return `${value.toFixed(digits)} ${units[unit]}`;
}
function deleteArtifactConfirmation(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
): string {
const size =
artifact.sizeBytes == null ? "" : `Espace libéré : ${formatBytes(artifact.sizeBytes)}.`;
return [
"Supprimer le modèle téléchargé ?",
`Serveur : ${server.name}`,
"Le serveur local restera configuré, mais IdeA devra retélécharger ce modèle au prochain lancement.",
size,
]
.filter(Boolean)
.join("\n");
}
export interface ModelServersPanelProps {
/** The model-server registry view-model (from `useModelServers`). */
vm: ModelServersViewModel;
@ -73,6 +103,14 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
if (saved) setDraft(null);
}
async function confirmDeleteArtifact(
server: LocalModelServerConfig,
artifact: Extract<ModelArtifact, { state: "downloaded" }>,
) {
if (!window.confirm(deleteArtifactConfirmation(server, artifact))) return;
await vm.deleteArtifact(server.id);
}
return (
<Panel
aria-label="local model servers"
@ -98,6 +136,11 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
{vm.error}
</p>
)}
{vm.notice && (
<p role="status" className="text-sm text-muted">
{vm.notice}
</p>
)}
{vm.servers.length === 0 && !draft && (
<p className="text-xs text-muted">
@ -107,40 +150,69 @@ export function ModelServersPanel({ vm }: ModelServersPanelProps) {
)}
<ul className="flex list-none flex-col gap-2 p-0">
{vm.servers.map((server) => (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
{vm.servers.map((server) => {
const artifact = server.artifact;
const downloaded =
artifact?.state === "downloaded" ? artifact : undefined;
const deletingArtifact = vm.deletingArtifactId === server.id;
const downloadingArtifact = artifact?.state === "downloading";
return (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
</span>
</span>
</span>
<span className="ml-auto flex items-center gap-1">
<Button
size="sm"
aria-label={`edit ${server.name}`}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
))}
<span className="ml-auto flex items-center gap-1">
{downloadingArtifact && (
<Button
size="sm"
variant="secondary"
aria-label={`download in progress ${server.name}`}
disabled
>
Téléchargement en cours
</Button>
)}
{downloaded && (
<Button
size="sm"
variant="danger"
aria-label={`delete downloaded model ${server.name}`}
onClick={() => void confirmDeleteArtifact(server, downloaded)}
loading={deletingArtifact}
disabled={vm.busy && !deletingArtifact}
>
{deletingArtifact ? "Suppression..." : "Supprimer le modèle téléchargé"}
</Button>
)}
<Button
size="sm"
aria-label={`edit ${server.name}`}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
);
})}
</ul>
{draft && (

View File

@ -6,7 +6,7 @@
* {@link ModelServerSelect} binding dropdown.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
@ -312,6 +312,95 @@ describe("ModelServersPanel wizard (F35 V2)", () => {
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
});
it("deletes a downloaded managed model artifact after confirmation without deleting the server config", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: {
state: "downloaded",
path: "/cache/unsloth/Qwen3.5-9B-GGUF/model.gguf",
sizeBytes: 1_500_000_000,
},
});
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
await waitFor(() => {
expect(confirm).toHaveBeenCalledWith(expect.stringMatching(/1\.5 GB/));
});
await waitFor(async () => {
const [server] = await modelServer.listModelServers();
expect(server).toMatchObject({
id: SERVER.id,
artifact: { state: "missing" },
});
});
expect(await screen.findByText(/Modèle téléchargé supprimé/i)).toBeTruthy();
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
confirm.mockRestore();
});
it("does not offer model artifact deletion for local .gguf or missing managed artifacts", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: { state: "missing" },
});
await modelServer.saveModelServer({
...SERVER,
id: "550e8400-e29b-41d4-a716-446655440001",
name: "Local file",
modelSource: { type: "localPath", path: "/models/qwen.gguf" },
artifact: { state: "notManaged" },
});
renderPanel(modelServer);
await screen.findByLabelText("edit Local A");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
expect(screen.queryByLabelText("delete downloaded model Local file")).toBeNull();
});
it("shows a disabled downloading state instead of a delete action while an artifact is in progress", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: { state: "downloading" },
});
renderPanel(modelServer);
const downloading = await screen.findByLabelText("download in progress Local A");
expect((downloading as HTMLButtonElement).disabled).toBe(true);
expect(downloading.textContent).toContain("Téléchargement en cours");
expect(screen.queryByLabelText("delete downloaded model Local A")).toBeNull();
});
it("shows a short inline error when artifact deletion is blocked", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer({
...SERVER,
artifact: {
state: "downloaded",
path: "/cache/model.gguf",
},
});
modelServer.markInUse(SERVER.id);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("delete downloaded model Local A"));
expect((await screen.findByRole("alert")).textContent).toMatch(
/téléchargement en cours ou agent actif/i,
);
expect(screen.getByLabelText("delete downloaded model Local A")).toBeTruthy();
confirm.mockRestore();
});
it("edits an existing server's served model name", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer(SERVER);

View File

@ -24,14 +24,20 @@ export interface ModelServersViewModel {
servers: LocalModelServerConfig[];
/** Last error message, or `null`. */
error: string | null;
/** Last non-blocking success message, or `null`. */
notice: string | null;
/** Whether a request is in flight. */
busy: boolean;
/** Server id whose managed artifact is currently being deleted, or `null`. */
deletingArtifactId: string | null;
/** Reloads the server list. */
reload: () => Promise<void>;
/** Creates or updates a server; returns the persisted config (or `null` on error). */
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
/** Deletes a server by id; returns `true` on success. */
remove: (serverId: string) => Promise<boolean>;
/** Deletes only the managed downloaded model artifact; returns `true` on success. */
deleteArtifact: (serverId: string) => Promise<boolean>;
/**
* Asks the backend to build the `llama-server` command line for a draft
* (never reconstructed client-side). Returns `null` when the draft is
@ -62,11 +68,14 @@ export function useModelServers(): ModelServersViewModel {
const { modelServer } = useGateways();
const [servers, setServers] = useState<LocalModelServerConfig[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [deletingArtifactId, setDeletingArtifactId] = useState<string | null>(null);
const reload = useCallback(async () => {
setBusy(true);
setError(null);
setNotice(null);
try {
setServers(await modelServer.listModelServers());
} catch (e) {
@ -84,6 +93,7 @@ export function useModelServers(): ModelServersViewModel {
async (config: LocalModelServerConfig) => {
setBusy(true);
setError(null);
setNotice(null);
try {
const saved = await modelServer.saveModelServer(config);
setServers((prev) => {
@ -110,6 +120,7 @@ export function useModelServers(): ModelServersViewModel {
async (serverId: string) => {
setBusy(true);
setError(null);
setNotice(null);
try {
await modelServer.deleteModelServer(serverId);
setServers((prev) => prev.filter((s) => s.id !== serverId));
@ -132,6 +143,37 @@ export function useModelServers(): ModelServersViewModel {
[modelServer],
);
const deleteArtifact = useCallback(
async (serverId: string) => {
setBusy(true);
setDeletingArtifactId(serverId);
setError(null);
setNotice(null);
try {
await modelServer.deleteModelArtifact(serverId);
setServers(await modelServer.listModelServers());
setNotice("Modèle téléchargé supprimé. Le serveur reste configuré.");
return true;
} catch (e) {
const code = codeOf(e);
if (code === "model_server_in_use") {
setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif.");
} else if (code === "invalid") {
setError("Aucun modèle téléchargé géré à supprimer.");
} else if (code === "not_configured") {
setError("Serveur introuvable.");
} else {
setError(describe(e));
}
return false;
} finally {
setDeletingArtifactId(null);
setBusy(false);
}
},
[modelServer],
);
const preview = useCallback(
async (config: LocalModelServerConfig) => {
try {
@ -145,7 +187,22 @@ export function useModelServers(): ModelServersViewModel {
[modelServer],
);
const clearError = useCallback(() => setError(null), []);
const clearError = useCallback(() => {
setError(null);
setNotice(null);
}, []);
return { servers, error, busy, reload, save, remove, preview, clearError };
return {
servers,
error,
notice,
busy,
deletingArtifactId,
reload,
save,
remove,
deleteArtifact,
preview,
clearError,
};
}