diff --git a/coding-agent/webui/app/src/App.test.tsx b/coding-agent/webui/app/src/App.test.tsx index 15b0848d4..2d881652b 100644 --- a/coding-agent/webui/app/src/App.test.tsx +++ b/coding-agent/webui/app/src/App.test.tsx @@ -151,15 +151,131 @@ describe("App session switching", () => { sessionsUrl = url; return Promise.resolve(jsonResponse({ sessions: [] })); } + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 0, version: "0.0.0" })); + } throw new Error(`unexpected fetch ${url}`); }), ); render(); - const baseUrl = screen.getByTitle("Server base URL") as HTMLInputElement; await waitFor(() => expect(sessionsUrl).toBe(`${window.location.origin}/sessions`)); - expect(baseUrl.value).toBe(window.location.origin); + // The default same-origin profile is seeded and persisted. + const stored = JSON.parse( + localStorage.getItem("coding-agent-webui-profiles") ?? "{}", + ) as { profiles?: Array<{ baseUrl?: string }> }; + expect(stored.profiles?.[0]?.baseUrl).toBe(window.location.origin); + }); + + it("switching the active profile swaps the backend and refreshes the session list", async () => { + const onA = session("session-a-0001", "model-a"); + const onB = session("session-b-0002", "model-b"); + localStorage.setItem( + "coding-agent-webui-profiles", + JSON.stringify({ + profiles: [ + { id: "pa", name: "backend-a", baseUrl: "http://a.test", apiKey: "" }, + { id: "pb", name: "backend-b", baseUrl: "http://b.test", apiKey: "" }, + ], + activeId: "pa", + }), + ); + const sessionListCalls: string[] = []; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === "http://a.test/sessions") { + sessionListCalls.push(url); + return Promise.resolve(jsonResponse({ sessions: [onA] })); + } + if (url === "http://b.test/sessions") { + sessionListCalls.push(url); + return Promise.resolve(jsonResponse({ sessions: [onB] })); + } + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 1, version: "1.0.0" })); + } + if (url.endsWith("/oauth/accounts")) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + // Backend A is active on load. + expect(await screen.findByRole("button", { name: /model-a/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: "connection" }).textContent).toContain( + "backend-a", + ); + + fireEvent.click(screen.getByRole("button", { name: "connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Switch" })); + + // The session list was cleared and re-fetched from backend B. + await waitFor(() => expect(sessionListCalls).toContain("http://b.test/sessions")); + expect(await screen.findByRole("button", { name: /model-b/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /model-a/i })).toBeNull(); + expect(screen.getByRole("button", { name: "connection" }).textContent).toContain( + "backend-b", + ); + }); + + it("discards a stale session list response from the old backend after a profile switch", async () => { + const onA = session("session-a-0001", "model-a"); + const onB = session("session-b-0002", "model-b"); + localStorage.setItem( + "coding-agent-webui-profiles", + JSON.stringify({ + profiles: [ + { id: "pa", name: "backend-a", baseUrl: "http://a.test", apiKey: "" }, + { id: "pb", name: "backend-b", baseUrl: "http://b.test", apiKey: "" }, + ], + activeId: "pa", + }), + ); + const oldList = deferred(); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === "http://a.test/sessions") { + // Old backend is slow: its list response is still in flight when the + // profile switch happens. + return oldList.promise; + } + if (url === "http://b.test/sessions") { + return Promise.resolve(jsonResponse({ sessions: [onB] })); + } + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 1, version: "1.0.0" })); + } + if (url.endsWith("/oauth/accounts")) { + return Promise.resolve(jsonResponse([])); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + // The initial list request to backend A is in flight (unresolved). + fireEvent.click(screen.getByRole("button", { name: "connection" })); + fireEvent.click(await screen.findByRole("button", { name: "Switch" })); + + // Backend B's list wins. + expect(await screen.findByRole("button", { name: /model-b/i })).toBeTruthy(); + + // The stale response from backend A resolves late and must be discarded. + oldList.resolve(jsonResponse({ sessions: [onA] })); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.queryByRole("button", { name: /model-a/i })).toBeNull(); + expect(screen.getByRole("button", { name: /model-b/i })).toBeTruthy(); }); it("uses prompt for completed restored sessions even when historical resume metadata remains", async () => { diff --git a/coding-agent/webui/app/src/App.tsx b/coding-agent/webui/app/src/App.tsx index 64f51f546..7032492ec 100644 --- a/coding-agent/webui/app/src/App.tsx +++ b/coding-agent/webui/app/src/App.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AgentClient, type RuntimeConfigPatch } from "./lib/api"; +import { + activeProfile, + defaultBaseUrl, + loadProfiles, + saveProfiles, + type ProfileStore, +} from "./lib/profiles"; import type { ApprovalPolicy, ApprovalScope, @@ -47,14 +54,6 @@ type Config = { model: string; }; -function defaultBaseUrl(): string { - const configured = import.meta.env.VITE_API_BASE_URL; - if (typeof configured === "string" && configured.trim()) { - return configured.trim(); - } - return window.location.origin; -} - function loadConfig(): Config { const defaults: Config = { baseUrl: defaultBaseUrl(), @@ -64,13 +63,21 @@ function loadConfig(): Config { provider: "kimi-code", model: "kimi-for-coding", }; + let merged = defaults; try { const raw = localStorage.getItem(LS_KEY); - if (raw) return { ...defaults, ...(JSON.parse(raw) as Partial) }; + if (raw) merged = { ...defaults, ...(JSON.parse(raw) as Partial) }; } catch { /* ignore */ } - return defaults; + // The active connection profile owns baseUrl/apiKey; the legacy config's + // copies are only migration input for the profiles store. + const active = activeProfile(loadProfiles()); + if (active) { + merged.baseUrl = active.baseUrl; + merged.apiKey = active.apiKey; + } + return merged; } function loadTheme(): Theme { @@ -98,6 +105,7 @@ function initialSidebarOpen(): boolean { export default function App() { const [config, setConfig] = useState(loadConfig); + const [profiles, setProfiles] = useState(loadProfiles); const [theme, setTheme] = useState(loadTheme); const [sessionId, setSessionId] = useState(null); const [sessionMode, setSessionMode] = useState<"prompt" | "resume">("prompt"); @@ -125,6 +133,7 @@ export default function App() { const abortRef = useRef(null); const followAbortRef = useRef(null); const loadSeqRef = useRef(0); + const sessionsSeqRef = useRef(0); const memorySeqRef = useRef(0); const resultSeqRef = useRef(0); const diffSeqRef = useRef(0); @@ -199,20 +208,62 @@ export default function App() { setSessionLoading(value); }, []); + // Persist profile changes and, when the active connection (baseUrl/apiKey) + // actually changed, switch backend: drop all session state tied to the old + // server and push the new connection through the normal config path (which + // recreates the client and re-fetches the session list). + const handleProfilesChange = useCallback( + (next: ProfileStore) => { + saveProfiles(next); + setProfiles(next); + const active = activeProfile(next); + if (!active) return; + if (active.baseUrl === config.baseUrl && active.apiKey === config.apiKey) return; + loadSeqRef.current += 1; + // Invalidate any in-flight session list fetch from the old backend so its + // late response cannot overwrite the new backend's list. + sessionsSeqRef.current += 1; + abortRef.current?.abort(); + abortRef.current = null; + followAbortRef.current?.abort(); + followAbortRef.current = null; + activeSessionRef.current = null; + setSessionId(null); + setSessionMode("prompt"); + setItems([]); + setSessions([]); + setDiffState(null); + setMemoryState(null); + setResultState(null); + setRailPanel(null); + setStreaming(false); + setSessionLoadingState(false); + setStatus("no session"); + patchConfig({ baseUrl: active.baseUrl, apiKey: active.apiKey }); + }, + [config.baseUrl, config.apiKey, patchConfig, setSessionLoadingState], + ); + const refreshSessions = useCallback(async () => { + // Refreshes can resolve out of order (e.g. a slow old backend vs. a fresh + // fetch after a profile switch); only the latest call may apply. Same + // seq-guard pattern as refreshMemory/refreshResult. + const sessionsSeq = ++sessionsSeqRef.current; setSessionsLoading(true); setSessionsError(null); try { const listed = await client.listSessions(); + if (sessionsSeqRef.current !== sessionsSeq) return; listed.sort( (a, b) => new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime(), ); setSessions(listed); } catch (e) { + if (sessionsSeqRef.current !== sessionsSeq) return; setSessionsError(`session list failed: ${msg(e)}`); } finally { - setSessionsLoading(false); + if (sessionsSeqRef.current === sessionsSeq) setSessionsLoading(false); } }, [client]); @@ -261,10 +312,10 @@ export default function App() { }, [client]); useEffect(() => { + // refreshSessions is keyed on the client, so this also re-fetches after a + // profile switch or connection edit recreates the client. void refreshSessions(); - // Config edits can pass through incomplete URLs; refresh explicitly after edits. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [refreshSessions]); const newSession = useCallback(async () => { const loadSeq = loadSeqRef.current + 1; @@ -644,6 +695,8 @@ export default function App() {
setSidebarOpen((v) => !v)} sessionId={sessionId} diff --git a/coding-agent/webui/app/src/components/ConnectionPanel.test.tsx b/coding-agent/webui/app/src/components/ConnectionPanel.test.tsx new file mode 100644 index 000000000..f59015958 --- /dev/null +++ b/coding-agent/webui/app/src/components/ConnectionPanel.test.tsx @@ -0,0 +1,280 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProfileStore } from "../lib/profiles"; +import ConnectionPanel from "./ConnectionPanel"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const twoProfiles = (): ProfileStore => ({ + profiles: [ + { id: "a", name: "local", baseUrl: "http://localhost:8080", apiKey: "" }, + { id: "b", name: "staging", baseUrl: "https://staging.example.com", apiKey: "k" }, + ], + activeId: "a", +}); + +function renderPanel(store: ProfileStore, onChange = vi.fn()) { + const utils = render( +
+ undefined} /> +
, + ); + return { ...utils, onChange }; +} + +describe("ConnectionPanel profile list", () => { + it("marks the active profile and guards its delete button", () => { + renderPanel(twoProfiles()); + + const activeRow = screen.getByText("local").closest("li")!; + expect((activeRow.querySelector("button[title*='active' i]") as HTMLButtonElement).disabled).toBe( + true, + ); + // Inactive profile's delete is enabled. + const inactiveRow = screen.getByText("staging").closest("li")!; + const del = Array.from(inactiveRow.querySelectorAll("button")).find( + (b) => b.textContent === "Delete", + ) as HTMLButtonElement; + expect(del.disabled).toBe(false); + }); + + it("activates another profile via Switch", () => { + const { onChange } = renderPanel(twoProfiles()); + + fireEvent.click(screen.getByRole("button", { name: "Switch" })); + + expect(onChange).toHaveBeenCalledWith({ ...twoProfiles(), activeId: "b" }); + }); + + it("deletes an inactive profile after confirmation", () => { + vi.stubGlobal("confirm", vi.fn(() => true)); + const { onChange } = renderPanel(twoProfiles()); + + const inactiveRow = screen.getByText("staging").closest("li")!; + const del = Array.from(inactiveRow.querySelectorAll("button")).find( + (b) => b.textContent === "Delete", + )!; + fireEvent.click(del); + + expect(onChange).toHaveBeenCalledWith({ + profiles: [twoProfiles().profiles[0]], + activeId: "a", + }); + }); +}); + +describe("ConnectionPanel new profile form", () => { + const fillForm = () => { + fireEvent.click(screen.getByRole("button", { name: "+ New" })); + fireEvent.change(screen.getByTitle("profile name"), { target: { value: "prod" } }); + fireEvent.change(screen.getByTitle("Server base URL"), { + target: { value: "https://prod.example.com" }, + }); + fireEvent.change(screen.getByPlaceholderText("X-API-Key"), { + target: { value: "prod-key" }, + }); + }; + + it("tests the connection with the form values and saves after success", async () => { + let healthUrl = ""; + let healthKey: string | null = null; + let sessionsKey: string | null = null; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/healthz")) { + healthUrl = url; + healthKey = new Headers(init?.headers).get("X-API-Key"); + return Promise.resolve(jsonResponse({ status: "ok", sessions: 4, version: "2.0.1" })); + } + if (url.endsWith("/sessions")) { + sessionsKey = new Headers(init?.headers).get("X-API-Key"); + return Promise.resolve(jsonResponse({ sessions: [] })); + } + return Promise.reject(new Error(`unstubbed fetch: ${url}`)); + }), + ); + const { onChange } = renderPanel(twoProfiles()); + + fillForm(); + // Save is disabled before a successful test. + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe( + true, + ); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + expect(await screen.findByText(/^✓ v2\.0\.1 · 4 sessions · \d+ms$/)).toBeTruthy(); + expect(healthUrl).toBe("https://prod.example.com/healthz"); + expect(healthKey).toBe("prod-key"); + // The authenticated probe must carry the form's key too. + expect(sessionsKey).toBe("prod-key"); + + const save = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + expect(save.disabled).toBe(false); + fireEvent.click(save); + + expect(onChange).toHaveBeenCalledTimes(1); + const next = onChange.mock.calls[0][0] as ProfileStore; + expect(next.profiles).toHaveLength(3); + expect(next.profiles[2]).toMatchObject({ + name: "prod", + baseUrl: "https://prod.example.com", + apiKey: "prod-key", + }); + expect(next.activeId).toBe("a"); + }); + + it("shows the error on test failure and requires 'save anyway' to save", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("boom", { status: 500 }))), + ); + const { onChange } = renderPanel(twoProfiles()); + + fillForm(); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + expect(await screen.findByText(/^✗ 500 boom$/)).toBeTruthy(); + const save = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + + fireEvent.click(screen.getByRole("checkbox")); + expect(save.disabled).toBe(false); + fireEvent.click(save); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("invalidates a passing test when a form field changes", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(jsonResponse({ status: "ok", sessions: 0, version: "1.0.0" })), + ), + ); + renderPanel(twoProfiles()); + + fillForm(); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + await screen.findByText(/^✓ v1\.0\.0/); + + fireEvent.change(screen.getByTitle("Server base URL"), { + target: { value: "https://other.example.com" }, + }); + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe( + true, + ); + }); + + it("rejects a wrong API key even when healthz passes", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 2, version: "2.0.0" })); + } + if (url.endsWith("/sessions")) { + return Promise.resolve(new Response("invalid api key", { status: 401 })); + } + return Promise.reject(new Error(`unstubbed fetch: ${url}`)); + }), + ); + const { onChange } = renderPanel(twoProfiles()); + + fillForm(); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + expect(await screen.findByText(/^✗ API key rejected \(401 invalid api key\)$/)).toBeTruthy(); + const save = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + // Only 'save anyway' can still persist a profile whose test failed. + fireEvent.click(screen.getByRole("checkbox")); + expect(save.disabled).toBe(false); + fireEvent.click(save); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it("ignores a test result that resolves after the form values changed", async () => { + let resolveFetch!: (value: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((res) => { + resolveFetch = res; + }), + ), + ); + renderPanel(twoProfiles()); + + fillForm(); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + + // Edit a field while the test request is in flight. + fireEvent.change(screen.getByPlaceholderText("X-API-Key"), { + target: { value: "edited-key" }, + }); + const save = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; + expect(save.disabled).toBe(true); + + // The stale success must not unlock Save for values that were never tested. + resolveFetch(jsonResponse({ status: "ok", sessions: 1, version: "9.9.9" })); + await new Promise((resolve) => setTimeout(resolve, 0)); + resolveFetch(jsonResponse({ sessions: [] })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(save.disabled).toBe(true); + expect(screen.queryByText(/^✓ /)).toBeNull(); + }); +}); + +describe("ConnectionPanel edit form", () => { + it("prefills the form and updates the profile on save", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 1, version: "3.1.0" })); + } + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + return Promise.reject(new Error(`unstubbed fetch: ${url}`)); + }), + ); + const { onChange } = renderPanel(twoProfiles()); + + const activeRow = screen.getByText("local").closest("li")!; + const edit = Array.from(activeRow.querySelectorAll("button")).find( + (b) => b.textContent === "Edit", + )!; + fireEvent.click(edit); + + expect((screen.getByTitle("profile name") as HTMLInputElement).value).toBe("local"); + expect((screen.getByTitle("Server base URL") as HTMLInputElement).value).toBe( + "http://localhost:8080", + ); + + fireEvent.change(screen.getByTitle("profile name"), { target: { value: "local-dev" } }); + fireEvent.click(screen.getByRole("button", { name: "Test connection" })); + await screen.findByText(/^✓ v3\.1\.0/); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + const next = onChange.mock.calls[0][0] as ProfileStore; + expect(next.profiles[0]).toMatchObject({ id: "a", name: "local-dev" }); + }); +}); diff --git a/coding-agent/webui/app/src/components/ConnectionPanel.tsx b/coding-agent/webui/app/src/components/ConnectionPanel.tsx new file mode 100644 index 000000000..5ff392ccf --- /dev/null +++ b/coding-agent/webui/app/src/components/ConnectionPanel.tsx @@ -0,0 +1,335 @@ +import { useEffect, useRef, useState } from "react"; +import { AgentClient } from "../lib/api"; +import { + addProfile, + removeProfile, + setActiveProfile, + updateProfile, + type ConnectionProfile, + type ProfileStore, +} from "../lib/profiles"; + +interface Props { + store: ProfileStore; + onChange: (store: ProfileStore) => void; + onClose: () => void; +} + +type Mode = { kind: "list" } | { kind: "new" } | { kind: "edit"; id: string }; + +type TestState = + | { state: "idle" } + | { state: "testing" } + | { state: "ok"; message: string } + | { state: "failed"; message: string }; + +const inputCls = + "w-full rounded-lg border border-border bg-surface-0 px-3 py-1.5 text-sm text-fg placeholder:text-muted focus:border-accent focus:outline-none"; + +function hostOf(baseUrl: string): string { + try { + return new URL(baseUrl).host || baseUrl; + } catch { + return baseUrl; + } +} + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +// Dropdown panel for managing connection profiles. Rendered by the header +// inside a `relative` wrapper marked with `data-connection-root`; clicks +// inside that root (including the toggle button) are not "outside" clicks. +export default function ConnectionPanel({ store, onChange, onClose }: Props) { + const rootRef = useRef(null); + const [mode, setMode] = useState({ kind: "list" }); + const [name, setName] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [test, setTest] = useState({ state: "idle" }); + const [saveAnyway, setSaveAnyway] = useState(false); + const [error, setError] = useState(null); + + // Refs mirror the current form values so a settling test promise can tell + // whether the values it validated are still what the form shows. + const baseUrlRef = useRef(baseUrl); + baseUrlRef.current = baseUrl; + const apiKeyRef = useRef(apiKey); + apiKeyRef.current = apiKey; + + useEffect(() => { + const onPointerDown = (e: MouseEvent) => { + const target = e.target; + if (!(target instanceof Element)) return; + if (target.closest("[data-connection-root]")) return; + onClose(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [onClose]); + + const openForm = (next: Mode) => { + setMode(next); + setError(null); + setTest({ state: "idle" }); + setSaveAnyway(false); + if (next.kind === "edit") { + const p = store.profiles.find((it) => it.id === next.id); + setName(p?.name ?? ""); + setBaseUrl(p?.baseUrl ?? ""); + setApiKey(p?.apiKey ?? ""); + } else { + setName(""); + setBaseUrl(""); + setApiKey(""); + } + }; + + // Any form edit invalidates the previous test result. + const onFieldChange = (setter: (v: string) => void) => (v: string) => { + setter(v); + setTest({ state: "idle" }); + setSaveAnyway(false); + }; + + // Snapshot of the values the in-flight test is validating. A late response + // only applies when the form still holds these exact values. + type TestSnapshot = { baseUrl: string; apiKey: string }; + const testSnapshotMatches = (snapshot: TestSnapshot) => + baseUrlRef.current.trim() === snapshot.baseUrl && apiKeyRef.current === snapshot.apiKey; + + const testConnection = async () => { + setTest({ state: "testing" }); + const started = performance.now(); + const snapshot: TestSnapshot = { baseUrl: baseUrl.trim(), apiKey }; + const client = new AgentClient({ baseUrl: snapshot.baseUrl, apiKey: snapshot.apiKey }); + try { + const h = await client.health(); + // /healthz is unauthenticated, so also probe an authenticated endpoint + // with the form's key; a wrong key must not unlock Save. + try { + await client.listSessions(); + } catch (e) { + const message = errMsg(e); + if (/^40[13]\b/.test(message)) { + throw new Error(`API key rejected (${message})`); + } + throw e; + } + if (!testSnapshotMatches(snapshot)) return; + const latency = Math.round(performance.now() - started); + setTest({ + state: "ok", + message: `v${h.version} · ${h.sessions} sessions · ${latency}ms`, + }); + } catch (e) { + if (!testSnapshotMatches(snapshot)) return; + setTest({ state: "failed", message: errMsg(e) }); + } + }; + + const canSave = + name.trim().length > 0 && + baseUrl.trim().length > 0 && + (test.state === "ok" || (test.state === "failed" && saveAnyway)); + + const save = () => { + if (!canSave) return; + const input = { name: name.trim(), baseUrl: baseUrl.trim(), apiKey }; + try { + if (mode.kind === "edit") { + onChange(updateProfile(store, mode.id, input)); + } else { + onChange(addProfile(store, input)); + } + setMode({ kind: "list" }); + } catch (e) { + setError(errMsg(e)); + } + }; + + const switchTo = (id: string) => { + onChange(setActiveProfile(store, id)); + onClose(); + }; + + const remove = (p: ConnectionProfile) => { + if (!window.confirm(`Delete profile "${p.name}" (${hostOf(p.baseUrl)})?`)) return; + try { + onChange(removeProfile(store, p.id)); + } catch (e) { + setError(errMsg(e)); + } + }; + + const formOpen = mode.kind !== "list"; + + return ( +
+
+ + connection profiles + + {!formOpen && ( + + )} +
+ + {error &&
{error}
} + + {!formOpen && ( +
    + {store.profiles.map((p) => { + const isActive = p.id === store.activeId; + const deletable = !isActive && store.profiles.length > 1; + return ( +
  • + +
    +
    {p.name}
    +
    + {hostOf(p.baseUrl)} +
    +
    + {!isActive && ( + + )} + + +
  • + ); + })} +
+ )} + + {formOpen && ( +
+ onFieldChange(setName)(e.target.value)} + /> + onFieldChange(setBaseUrl)(e.target.value)} + /> + onFieldChange(setApiKey)(e.target.value)} + /> + + {test.state === "ok" && ( +
+ ✓ {test.message} +
+ )} + {test.state === "failed" && ( +
+ ✗ {test.message} +
+ )} + {test.state === "failed" && ( + + )} + +
+ + + +
+
+ )} +
+ ); +} diff --git a/coding-agent/webui/app/src/components/Header.test.tsx b/coding-agent/webui/app/src/components/Header.test.tsx index 2da390ac2..f8acd39dc 100644 --- a/coding-agent/webui/app/src/components/Header.test.tsx +++ b/coding-agent/webui/app/src/components/Header.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AgentClient } from "../lib/api"; +import type { ProfileStore } from "../lib/profiles"; import Header, { PROVIDERS } from "./Header"; afterEach(() => { @@ -13,6 +14,11 @@ afterEach(() => { const client = new AgentClient({ baseUrl: "http://test" }); +const profiles: ProfileStore = { + profiles: [{ id: "p1", name: "test-server", baseUrl: "http://test", apiKey: "" }], + activeId: "p1", +}; + const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200, @@ -32,6 +38,8 @@ function renderHeader(provider = "") {
undefined} + profiles={profiles} + onProfilesChange={() => undefined} onNewSession={() => undefined} onToggleSidebar={() => undefined} sessionId={null} @@ -116,3 +124,67 @@ describe("Header provider dropdown", () => { ).toBe(false); }); }); + +describe("Header connection indicator", () => { + it("shows the active profile name and server version after a successful health check", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return Promise.resolve(jsonResponse({ status: "ok", sessions: 3, version: "1.2.3" })); + } + if (url.endsWith("/oauth/accounts")) { + return Promise.resolve(jsonResponse([])); + } + return Promise.reject(new Error(`unstubbed fetch: ${url}`)); + }), + ); + + renderHeader(); + + const indicator = screen.getByRole("button", { name: "connection" }); + expect(indicator.textContent).toContain("test-server"); + await waitFor(() => expect(indicator.textContent).toContain("v1.2.3")); + }); + + it("keeps the indicator without a version when the health check fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return Promise.resolve(new Response("down", { status: 502 })); + } + if (url.endsWith("/oauth/accounts")) { + return Promise.resolve(jsonResponse([])); + } + return Promise.reject(new Error(`unstubbed fetch: ${url}`)); + }), + ); + + renderHeader(); + + const indicator = screen.getByRole("button", { name: "connection" }); + await waitFor(() => + expect(indicator.getAttribute("title")).toContain("connection failed"), + ); + expect(indicator.textContent).not.toMatch(/v\d/); + }); + + it("no longer renders the always-visible base URL and API key inputs", () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + + renderHeader(); + + expect(screen.queryByTitle("Server base URL")).toBeNull(); + expect(screen.queryByPlaceholderText("X-API-Key")).toBeNull(); + // repo path / provider / model / theme controls are still there. + expect(screen.getByPlaceholderText(/repo path/i)).toBeTruthy(); + expect(screen.getByTitle("provider")).toBeTruthy(); + expect(screen.getByRole("button", { name: "toggle theme" })).toBeTruthy(); + }); +}); diff --git a/coding-agent/webui/app/src/components/Header.tsx b/coding-agent/webui/app/src/components/Header.tsx index 2d054edb0..e7907b76d 100644 --- a/coding-agent/webui/app/src/components/Header.tsx +++ b/coding-agent/webui/app/src/components/Header.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; import type { AgentClient } from "../lib/api"; +import { activeProfile, type ProfileStore } from "../lib/profiles"; import type { ApprovalPolicy } from "../lib/types"; +import ConnectionPanel from "./ConnectionPanel"; interface Config { baseUrl: string; @@ -35,6 +37,8 @@ const isCodexProvider = (p: string) => p === "codex" || p.startsWith("codex:"); interface Props { config: Config; onConfigChange: (patch: Partial) => void; + profiles: ProfileStore; + onProfilesChange: (store: ProfileStore) => void; onNewSession: () => void; onToggleSidebar: () => void; sessionId: string | null; @@ -49,6 +53,8 @@ interface Props { export default function Header({ config, onConfigChange, + profiles, + onProfilesChange, onNewSession, onToggleSidebar, sessionId, @@ -64,6 +70,38 @@ export default function Header({ // Connected codex OAuth account keys ("codex", "codex: