diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 3132b47..6134dac 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -1159,6 +1159,23 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [ detect: "codex --version", cwdTemplate: "{projectRoot}", }, + { + id: "mock-ollama", + name: "Ollama / OpenAI-compatible local model", + command: "openai-compatible", + args: [], + contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, + detect: null, + cwdTemplate: "{projectRoot}", + structuredAdapter: "openAiCompatible", + chatHttp: { + endpoint: "http://localhost:11434/v1", + model: "qwen2.5-coder", + requestTimeoutMs: 120_000, + connectTimeoutMs: 5_000, + maxToolIterations: 16, + }, + }, ]; /** diff --git a/frontend/src/adapters/mock/profile.test.ts b/frontend/src/adapters/mock/profile.test.ts index ce15288..6d2c361 100644 --- a/frontend/src/adapters/mock/profile.test.ts +++ b/frontend/src/adapters/mock/profile.test.ts @@ -22,14 +22,16 @@ function customProfile(id: string, command: string): AgentProfile { describe("MockProfileGateway", () => { it("firstRunState is first-run with only the selectable reference profiles", async () => { - // §17.3/D7: only structured-drivable profiles (Claude/Codex) are offered; - // Gemini/Aider are filtered out of the selection path server-side. + // §17.3/D7: only structured-drivable profiles are offered (Claude/Codex CLIs + // + the OpenAI-compatible local/LAN adapter, ticket #14); Gemini/Aider are + // filtered out of the selection path server-side. const gw = new MockProfileGateway(); const state = await gw.firstRunState(); expect(state.isFirstRun).toBe(true); expect(state.referenceProfiles.map((p) => p.command)).toEqual([ "claude", "codex", + "openai-compatible", ]); }); @@ -51,6 +53,7 @@ describe("MockProfileGateway", () => { expect(byCommand).toEqual({ claude: true, codex: false, + "openai-compatible": false, }); }); diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index da32256..d4a5945 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -626,6 +626,42 @@ export type ContextInjection = /** The four injection strategy discriminants. */ export type InjectionStrategy = ContextInjection["strategy"]; +/** + * Structured-execution adapter of a profile (mirror of the backend + * `StructuredAdapter`, camelCase wire format). Absent (`structuredAdapter` + * omitted) ⇒ the profile is a plain TUI/PTY agent (historical behaviour); + * present ⇒ the profile is selectable as a structured AI agent: + * - `claude` / `codex`: driven by a spawned CLI binary, + * - `openAiCompatible`: driven by IdeA's native HTTP adapter against an + * OpenAI-compatible chat server (Ollama, llama.cpp, a LAN runtime) — see + * {@link HttpChatConfig}. + */ +export type StructuredAdapter = "claude" | "codex" | "openAiCompatible"; + +/** + * HTTP configuration of an OpenAI-compatible chat server (mirror of the backend + * `HttpChatConfig`, camelCase wire format). Carried by a profile whose + * {@link StructuredAdapter} is `openAiCompatible`. + * + * Transparent by design: secrets are never stored here — `apiKeyEnv` is the + * *name* of an environment variable, never the key itself (the backend resolves + * it at call time). + */ +export interface HttpChatConfig { + /** Root or `/chat/completions` endpoint of the HTTP server (http/https). */ + endpoint: string; + /** Model name sent in the `/chat/completions` payload. */ + model: string; + /** Name of the env var holding the API key (optional). Never the key. */ + apiKeyEnv?: string; + /** Per-turn request timeout, in milliseconds. */ + requestTimeoutMs?: number; + /** Connection timeout, in milliseconds. */ + connectTimeoutMs?: number; + /** Tool-calling re-loop guard for one turn (defaults to ~16 backend-side). */ + maxToolIterations?: number; +} + /** * A declarative AI-CLI profile (mirror of the backend `AgentProfile`). `id` is a * UUID string; `detect` is the optional detection command line. @@ -641,6 +677,19 @@ export interface AgentProfile { /** Optional CLI flags for agent-session continuity: `assignFlag` to bind a * conversation id at launch, `resumeFlag` to resume an existing conversation. */ session?: { assignFlag?: string; resumeFlag: string }; + /** + * Structured-execution adapter (mirror of the backend `structured_adapter`, + * `skip_serializing_if = Option::is_none`). Absent ⇒ plain TUI/PTY profile; + * present ⇒ selectable structured AI agent. Additive: existing Claude/Codex + * profiles are unaffected. + */ + structuredAdapter?: StructuredAdapter; + /** + * HTTP config for a `openAiCompatible` structured adapter (mirror of the + * backend `chat_http`, `skip_serializing_if = Option::is_none`). Absent for + * every historical profile and every non-HTTP adapter. + */ + chatHttp?: HttpChatConfig; } /** Availability of a candidate profile after detection (mirror of the DTO). */ diff --git a/frontend/src/features/first-run/FirstRunWizard.test.tsx b/frontend/src/features/first-run/FirstRunWizard.test.tsx index 56a6998..03caf1a 100644 --- a/frontend/src/features/first-run/FirstRunWizard.test.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.test.tsx @@ -173,6 +173,134 @@ describe("FirstRunWizard (with MockProfileGateway)", () => { }); }); +describe("FirstRunWizard — OpenAI-compatible local/LAN profile (ticket #14)", () => { + const OLLAMA = "Ollama / OpenAI-compatible local model"; + + it("offers the local model as a selectable, pre-filled HTTP config row", async () => { + renderWizard(); + await waitForLoaded(); + + expect(screen.getByText(OLLAMA)).toBeTruthy(); + // The structured HTTP fields are rendered, pre-filled from the reference. + expect( + (screen.getByLabelText(`${OLLAMA} endpoint`) as HTMLInputElement).value, + ).toBe("http://localhost:11434/v1"); + expect( + (screen.getByLabelText(`${OLLAMA} model`) as HTMLInputElement).value, + ).toBe("qwen2.5-coder"); + // Claude/Codex rows must NOT grow HTTP fields (additive, no regression). + expect(screen.queryByLabelText("Claude Code endpoint")).toBeNull(); + }); + + it("labels the API key field as an env var NAME and flags a raw key", async () => { + renderWizard(); + await waitForLoaded(); + + const apiKey = screen.getByLabelText(`${OLLAMA} api key env`) as HTMLInputElement; + // The label/placeholder make the env-var-name intent explicit. + expect(apiKey.placeholder.toLowerCase()).toContain("variable name"); + + // A raw secret is not a valid env var identifier ⇒ inline error. + fireEvent.change(apiKey, { target: { value: "sk-secret-123" } }); + expect( + screen.getByText(/valid env var name \(not the key itself\)/i), + ).toBeTruthy(); + + // A proper env var name clears the error. + fireEvent.change(apiKey, { target: { value: "OPENAI_API_KEY" } }); + expect( + screen.queryByText(/valid env var name \(not the key itself\)/i), + ).toBeNull(); + }); + + it("flags an invalid endpoint scheme inline", async () => { + renderWizard(); + await waitForLoaded(); + + const endpoint = screen.getByLabelText(`${OLLAMA} endpoint`); + fireEvent.change(endpoint, { target: { value: "ftp://oops" } }); + expect(screen.getByText(/must start with http:\/\/ or https:\/\//i)).toBeTruthy(); + }); + + it("exposes the timeout / tool-guard fields pre-filled from the reference", async () => { + renderWizard(); + await waitForLoaded(); + + expect( + (screen.getByLabelText(`${OLLAMA} request timeout`) as HTMLInputElement).value, + ).toBe("120000"); + expect( + (screen.getByLabelText(`${OLLAMA} connect timeout`) as HTMLInputElement).value, + ).toBe("5000"); + expect( + (screen.getByLabelText(`${OLLAMA} max tool iterations`) as HTMLInputElement) + .value, + ).toBe("16"); + }); + + it("persists edited timeouts / tool-guard round-trip into chatHttp", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); + fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { + target: { value: "90000" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} connect timeout`), { + target: { value: "3000" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} max tool iterations`), { + target: { value: "8" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const ollama = saved.find((p) => p.command === "openai-compatible"); + expect(ollama?.chatHttp?.requestTimeoutMs).toBe(90000); + expect(ollama?.chatHttp?.connectTimeoutMs).toBe(3000); + expect(ollama?.chatHttp?.maxToolIterations).toBe(8); + }); + }); + + it("flags a zero timeout inline (mirror of the backend guard)", async () => { + renderWizard(); + await waitForLoaded(); + + fireEvent.change(screen.getByLabelText(`${OLLAMA} request timeout`), { + target: { value: "0" }, + }); + expect(screen.getByText(/positive integer \(ms\)/i)).toBeTruthy(); + }); + + it("persists the edited HTTP config round-trip when selected and saved", async () => { + const { profile } = renderWizard(); + await waitForLoaded(); + + // Select the local model and edit its model name. + fireEvent.click(screen.getByLabelText(`use ${OLLAMA}`)); + fireEvent.change(screen.getByLabelText(`${OLLAMA} model`), { + target: { value: "qwen3" }, + }); + fireEvent.change(screen.getByLabelText(`${OLLAMA} api key env`), { + target: { value: "LAN_KEY" }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Save and continue" })); + + await waitFor(async () => { + const saved = await profile.listProfiles(); + const ollama = saved.find((p) => p.command === "openai-compatible"); + expect(ollama?.structuredAdapter).toBe("openAiCompatible"); + expect(ollama?.chatHttp?.model).toBe("qwen3"); + expect(ollama?.chatHttp?.apiKeyEnv).toBe("LAN_KEY"); + // The endpoint round-trips untouched. + expect(ollama?.chatHttp?.endpoint).toBe("http://localhost:11434/v1"); + }); + }); +}); + describe("FirstRunWizard reopening after the first run (forceOpen)", () => { /** A gateway whose first run is already done (profiles configured). */ async function configuredGateway() { diff --git a/frontend/src/features/first-run/FirstRunWizard.tsx b/frontend/src/features/first-run/FirstRunWizard.tsx index 3c5e274..1476ed0 100644 --- a/frontend/src/features/first-run/FirstRunWizard.tsx +++ b/frontend/src/features/first-run/FirstRunWizard.tsx @@ -15,10 +15,15 @@ * `./profile`. */ -import type { AgentProfile } from "@/domain"; +import type { AgentProfile, HttpChatConfig } from "@/domain"; import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared"; import { useFirstRun, type WizardEntry } from "./useFirstRun"; -import { parseArgs, validateProfile } from "./profile"; +import { + defaultHttpChatConfig, + parseArgs, + validateProfile, + type ProfileErrors, +} from "./profile"; /** A small caption above a control. */ function Caption({ children }: { children: React.ReactNode }) { @@ -174,6 +179,133 @@ function ProfileRow({ onChange={(e) => onChange({ ...profile, args: parseArgs(e.target.value) })} /> + + {profile.structuredAdapter === "openAiCompatible" && ( + + )} ); } + +/** Parses a number field: blank ⇒ `undefined` (backend applies its default). */ +function parseOptInt(raw: string): number | undefined { + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + const n = Number(trimmed); + return Number.isFinite(n) ? n : NaN; +} + +/** + * The OpenAI-compatible (local/LAN) HTTP config section: endpoint, model, the + * *name* of the API-key env var (never the key), and optional timeouts / tool + * guard. Rendered only for a `openAiCompatible` profile; edits flow back through + * `onChange` as a patched `chatHttp`. + */ +function HttpChatFields({ + profile, + errors, + onChange, +}: { + profile: AgentProfile; + errors: ProfileErrors; + onChange: (p: AgentProfile) => void; +}) { + const http = profile.chatHttp ?? defaultHttpChatConfig(); + const patch = (next: Partial) => + onChange({ ...profile, chatHttp: { ...http, ...next } }); + + return ( +
+ + Local / LAN model (OpenAI-compatible) + + + + + + + + +
+ + + + + +
+
+ ); +} diff --git a/frontend/src/features/first-run/profile.test.ts b/frontend/src/features/first-run/profile.test.ts index aaee781..cc1c91c 100644 --- a/frontend/src/features/first-run/profile.test.ts +++ b/frontend/src/features/first-run/profile.test.ts @@ -7,12 +7,15 @@ import { describe, it, expect } from "vitest"; import type { AgentProfile } from "@/domain"; import { + defaultHttpChatConfig, defaultInjection, emptyCustomProfile, isProfileValid, isRelativeSafe, isValidEnvVar, + isValidHttpUrl, parseArgs, + validateHttpChatConfig, validateProfile, } from "./profile"; @@ -93,6 +96,99 @@ describe("validateProfile / isProfileValid", () => { }); }); +describe("isValidHttpUrl", () => { + it("accepts http/https endpoints", () => { + expect(isValidHttpUrl("http://localhost:11434/v1")).toBe(true); + expect(isValidHttpUrl("https://lan.host:8080/v1")).toBe(true); + }); + it("rejects empty, non-http and other schemes", () => { + expect(isValidHttpUrl("")).toBe(false); + expect(isValidHttpUrl(" ")).toBe(false); + expect(isValidHttpUrl("ftp://host")).toBe(false); + expect(isValidHttpUrl("localhost:11434")).toBe(false); + // Leading whitespace fails the raw prefix check, mirroring the backend. + expect(isValidHttpUrl(" http://host")).toBe(false); + }); +}); + +describe("validateHttpChatConfig", () => { + it("a well-formed local model config is valid", () => { + expect(validateHttpChatConfig(defaultHttpChatConfig())).toEqual({}); + }); + it("reports a bad endpoint scheme and an empty model", () => { + const errors = validateHttpChatConfig({ endpoint: "ftp://x", model: " " }); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + }); + it("apiKeyEnv must be an env var NAME, never a raw key", () => { + // A plausible raw key contains characters illegal in an env var identifier. + const withKey = validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + apiKeyEnv: "sk-abc123", + }); + expect(withKey.apiKeyEnv).toBeDefined(); + // A valid identifier passes; blank/undefined is allowed (optional). + expect( + validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + apiKeyEnv: "OPENAI_API_KEY", + }).apiKeyEnv, + ).toBeUndefined(); + expect( + validateHttpChatConfig({ endpoint: "http://host", model: "m" }).apiKeyEnv, + ).toBeUndefined(); + }); + it("rejects zero / non-integer timeouts and tool-iteration guard", () => { + const errors = validateHttpChatConfig({ + endpoint: "http://host", + model: "m", + requestTimeoutMs: 0, + connectTimeoutMs: -1, + maxToolIterations: 1.5, + }); + expect(errors.requestTimeoutMs).toBeDefined(); + expect(errors.connectTimeoutMs).toBeDefined(); + expect(errors.maxToolIterations).toBeDefined(); + }); +}); + +describe("validateProfile for openAiCompatible profiles", () => { + function ollama(overrides: Partial = {}): AgentProfile { + return base({ + name: "Ollama", + command: "openai-compatible", + structuredAdapter: "openAiCompatible", + chatHttp: defaultHttpChatConfig(), + ...overrides, + }); + } + it("the reference local-model profile is valid", () => { + expect(validateProfile(ollama())).toEqual({}); + expect(isProfileValid(ollama())).toBe(true); + }); + it("surfaces chatHttp field errors through the profile validator", () => { + const errors = validateProfile( + ollama({ chatHttp: { endpoint: "nope", model: "" } }), + ); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + expect(isProfileValid(ollama({ chatHttp: { endpoint: "nope", model: "m" } }))).toBe( + false, + ); + }); + it("a missing chatHttp on an openAiCompatible profile is invalid", () => { + const errors = validateProfile(ollama({ chatHttp: undefined })); + expect(errors.endpoint).toBeDefined(); + expect(errors.model).toBeDefined(); + }); + it("does not touch validation of Claude/Codex (non-structured-http) profiles", () => { + // A plain profile with no structuredAdapter never triggers chatHttp checks. + expect(validateProfile(base())).toEqual({}); + }); +}); + describe("defaultInjection", () => { it("produces a sensible default per strategy", () => { expect(defaultInjection("conventionFile")).toEqual({ diff --git a/frontend/src/features/first-run/profile.ts b/frontend/src/features/first-run/profile.ts index 374e1c6..39988a4 100644 --- a/frontend/src/features/first-run/profile.ts +++ b/frontend/src/features/first-run/profile.ts @@ -7,12 +7,26 @@ import type { AgentProfile, ContextInjection, + HttpChatConfig, InjectionStrategy, } from "@/domain"; /** A field-keyed validation error map (empty ⇒ valid). */ export type ProfileErrors = Partial< - Record<"name" | "command" | "target" | "flag" | "var", string> + Record< + | "name" + | "command" + | "target" + | "flag" + | "var" + | "endpoint" + | "model" + | "apiKeyEnv" + | "requestTimeoutMs" + | "connectTimeoutMs" + | "maxToolIterations", + string + > >; /** Whether a path is a relative, traversal-free file name (mirror of backend). */ @@ -29,6 +43,58 @@ export function isValidEnvVar(v: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(v); } +/** + * Whether a string is a valid `http://`/`https://` endpoint (mirror of the + * backend `HttpChatConfig::new`: non-empty once trimmed, and the raw value + * starts with `http://` or `https://`). + */ +export function isValidHttpUrl(v: string): boolean { + if (v.trim().length === 0) return false; + return v.startsWith("http://") || v.startsWith("https://"); +} + +/** + * Whether a value is an acceptable positive-integer timeout/guard (mirror of the + * backend, which rejects `0` and only ever holds `u32`/`u16`). `undefined` means + * "left blank" and is valid (the backend applies its own default). + */ +function isValidPositiveInt(value: number | undefined, max: number): boolean { + if (value === undefined) return true; + return Number.isInteger(value) && value > 0 && value <= max; +} + +const MAX_U32 = 0xffff_ffff; +const MAX_U16 = 0xffff; + +/** + * Validates an {@link HttpChatConfig} draft the way the backend + * `HttpChatConfig::new` would, so the wizard can surface errors before any + * `invoke`. Returns an empty object when the draft is valid. + */ +export function validateHttpChatConfig(c: HttpChatConfig): ProfileErrors { + const errors: ProfileErrors = {}; + if (!isValidHttpUrl(c.endpoint)) { + errors.endpoint = "Endpoint must start with http:// or https://."; + } + if (c.model.trim().length === 0) { + errors.model = "Model is required."; + } + // The API key field holds the NAME of an environment variable, never a key. + if (c.apiKeyEnv !== undefined && c.apiKeyEnv.length > 0 && !isValidEnvVar(c.apiKeyEnv)) { + errors.apiKeyEnv = "Must be a valid env var name (not the key itself)."; + } + if (!isValidPositiveInt(c.requestTimeoutMs, MAX_U32)) { + errors.requestTimeoutMs = "Must be a positive integer (ms)."; + } + if (!isValidPositiveInt(c.connectTimeoutMs, MAX_U32)) { + errors.connectTimeoutMs = "Must be a positive integer (ms)."; + } + if (!isValidPositiveInt(c.maxToolIterations, MAX_U16)) { + errors.maxToolIterations = "Must be a positive integer."; + } + return errors; +} + /** * Validates a profile draft the way the backend would, so the wizard can surface * errors before any `invoke`. Returns an empty object when the draft is valid. @@ -48,6 +114,17 @@ export function validateProfile(p: AgentProfile): ProfileErrors { } else if (ci.strategy === "env") { if (!isValidEnvVar(ci.var)) errors.var = "Must be a valid env var identifier."; } + + // An OpenAI-compatible profile additionally carries an HTTP chat config; the + // backend refuses to persist it unless it is well-formed, so mirror that here. + if (p.structuredAdapter === "openAiCompatible") { + if (!p.chatHttp) { + errors.endpoint = "Endpoint must start with http:// or https://."; + errors.model = "Model is required."; + } else { + Object.assign(errors, validateHttpChatConfig(p.chatHttp)); + } + } return errors; } @@ -73,6 +150,21 @@ export function defaultInjection(strategy: InjectionStrategy): ContextInjection } } +/** + * A sensible default {@link HttpChatConfig} for a fresh OpenAI-compatible + * profile draft — pre-fills a local Ollama endpoint so the form starts valid. + * Mirrors the backend reference profile (`http://localhost:11434/v1`). + */ +export function defaultHttpChatConfig(): HttpChatConfig { + return { + endpoint: "http://localhost:11434/v1", + model: "qwen2.5-coder", + requestTimeoutMs: 120_000, + connectTimeoutMs: 5_000, + maxToolIterations: 16, + }; +} + /** A fresh, empty custom-profile draft (id minted client-side). */ export function emptyCustomProfile(): AgentProfile { return { diff --git a/frontend/src/features/terminals/TerminalView.test.tsx b/frontend/src/features/terminals/TerminalView.test.tsx index 049d566..b24ab37 100644 --- a/frontend/src/features/terminals/TerminalView.test.tsx +++ b/frontend/src/features/terminals/TerminalView.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/features/terminals/TerminalView.tsx b/frontend/src/features/terminals/TerminalView.tsx index a9fb4d2..20dd4cc 100644 --- a/frontend/src/features/terminals/TerminalView.tsx +++ b/frontend/src/features/terminals/TerminalView.tsx @@ -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(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(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 (
+ 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. */} +
+ {openError && ( +
+ {openError} +
+ )} +
); }