feat(ui): profils locaux/LAN OpenAI-compatible dans le first-run et les terminaux (#14)
Câble la surface frontend des profils IA locaux/LAN OpenAI-compatible,
en parité avec l'adapter backend additif (aab4bca).
- domain: types de profil OpenAI-compatible
- first-run: édition/validation du profil dans le FirstRunWizard
- adapters/mock: mock de profil pour les tests
- terminals: rendu des round-trips et erreurs endpoint (role=alert)
Validé QA (frontend GO): typecheck exit 0, vitest 59 fichiers / 566 tests,
0 echec ; couverture timeouts round-trip + erreur endpoint role=alert
prouvees ; pas de regression sur les tests de bail headless.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -10,7 +10,7 @@
|
||||
* layout change) must **detach**, NEVER **close** — the backend PTY must survive
|
||||
* so a running AI isn't cut off. Re-mounting with a known session re-attaches.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type {
|
||||
@ -205,3 +205,88 @@ describe("TerminalView (with MockTerminalGateway)", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The launch-error surface (ticket #14 F3) can only be observed when xterm
|
||||
// actually mounts — otherwise the effect bails before ever calling the opener.
|
||||
// jsdom lacks `matchMedia`/`ResizeObserver`, which is exactly what makes
|
||||
// `term.open` throw. This block installs minimal polyfills so xterm mounts and
|
||||
// the opener genuinely runs (and rejects), letting us assert the real rendered
|
||||
// error. Scoped + torn down so the rest of the file keeps its headless
|
||||
// bail-graceful contract untouched.
|
||||
describe("TerminalView — visible launch-failure surface (ticket #14 F3)", () => {
|
||||
const w = window as unknown as {
|
||||
matchMedia?: (q: string) => MediaQueryList;
|
||||
};
|
||||
const savedMatchMedia = w.matchMedia;
|
||||
const savedResizeObserver = globalThis.ResizeObserver;
|
||||
|
||||
beforeAll(() => {
|
||||
w.matchMedia = (query: string) =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
}) as unknown as MediaQueryList;
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
w.matchMedia = savedMatchMedia;
|
||||
globalThis.ResizeObserver = savedResizeObserver;
|
||||
});
|
||||
|
||||
it("sanity: with the polyfills xterm mounts and the opener runs", async () => {
|
||||
// Guards the premise of the tests below: if this fails, the opener never
|
||||
// fired and the error assertions would be vacuous.
|
||||
const open = vi.fn(async () => makeHandle({ sessionId: "up-1" }));
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open });
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("renders an 'endpoint unavailable' launch failure as a visible alert without blocking the UI", async () => {
|
||||
// An OpenAI-compatible agent whose endpoint is down rejects the launch with a
|
||||
// Start error. The cell must show a visible, accessible error message — never
|
||||
// throw, never crash — so IdeA stays usable and other cells keep working.
|
||||
const open = vi.fn(async () => {
|
||||
throw {
|
||||
code: "AGENT_SESSION_START",
|
||||
message: "endpoint indisponible: http://localhost:11434/v1",
|
||||
};
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open }),
|
||||
).not.toThrow();
|
||||
|
||||
// The rejection is actually exercised (the opener was invoked and threw).
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
|
||||
// A real, accessible error message is rendered to the user (role="alert"),
|
||||
// carrying the endpoint diagnostic — not just painted into the xterm buffer.
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toMatch(/Échec du lancement de l'agent/);
|
||||
expect(alert.textContent).toMatch(/endpoint indisponible/);
|
||||
|
||||
// The cell stays mounted and interactive (not a blank/blocked screen).
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows NO failure banner on a successful launch", async () => {
|
||||
// Guard: the alert is strictly a failure surface — a healthy cell has none.
|
||||
const open = vi.fn(async () => makeHandle({ sessionId: "ok-1" }));
|
||||
renderView(new MockTerminalGateway(), "/cwd", { open });
|
||||
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByTestId("terminal-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
* fresh one. If the session is gone (was explicitly closed), it opens fresh.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
@ -108,6 +108,12 @@ export function TerminalView({
|
||||
}: TerminalViewProps) {
|
||||
const { terminal } = useGateways();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
// A user-visible launch failure (e.g. an OpenAI-compatible endpoint that is
|
||||
// unreachable). Rendered as a DOM `role="alert"` banner over the cell so the
|
||||
// failure is always visible and accessible — not only painted into the xterm
|
||||
// buffer (which is invisible to assistive tech and absent when xterm can't
|
||||
// mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
|
||||
const [openError, setOpenError] = useState<string | null>(null);
|
||||
|
||||
// The opener (`open` or the terminal gateway) is read through a ref so the
|
||||
// effect does NOT depend on its identity. Otherwise every parent re-render
|
||||
@ -138,6 +144,9 @@ export function TerminalView({
|
||||
const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
|
||||
if (!container || !opener) return;
|
||||
|
||||
// Fresh (re)mount: clear any prior failure banner before we try to open.
|
||||
setOpenError(null);
|
||||
|
||||
const term = new Terminal({
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
@ -218,6 +227,10 @@ export function TerminalView({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A genuine launch failure (unreachable endpoint, model missing, network
|
||||
// drop…). Surface it as an accessible DOM banner AND as a red line in the
|
||||
// buffer. The cell renders as failed; IdeA stays usable (other cells work).
|
||||
setOpenError(`Échec du lancement de l'agent : ${describe(e)}`);
|
||||
term.write(
|
||||
`\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
|
||||
);
|
||||
@ -307,10 +320,41 @@ export function TerminalView({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="terminal-view"
|
||||
style={{ width: "100%", height: "100%", minHeight: "16rem" }}
|
||||
/>
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: "16rem",
|
||||
}}
|
||||
>
|
||||
{/* xterm mounts into this inner node; the error banner is a sibling so
|
||||
React never fights xterm over the same subtree. */}
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{openError && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="terminal-error"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: "0.5rem 0.75rem",
|
||||
background: "rgba(120, 20, 20, 0.92)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{openError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user