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 && ( + openForm({ kind: "new" })} + > + + New + + )} + + + {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 && ( + switchTo(p.id)} + > + Switch + + )} + openForm({ kind: "edit", id: p.id })} + > + Edit + + remove(p)} + > + Delete + + + ); + })} + + )} + + {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" && ( + + setSaveAnyway(e.target.checked)} + /> + save anyway (connection test failed) + + )} + + + void testConnection()} + > + {test.state === "testing" ? "Testing…" : "Test connection"} + + + Save + + setMode({ kind: "list" })} + > + Cancel + + + + )} + + ); +} 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:") appended to // the static provider list; empty when the server lacks the endpoints. const [oauthProviders, setOauthProviders] = useState([]); + const [panelOpen, setPanelOpen] = useState(false); + // Last background health check against the active profile's server. + const [health, setHealth] = useState< + | { state: "unknown" } + | { state: "ok"; version: string } + | { state: "failed"; error: string } + >({ state: "unknown" }); + + // Debounced background health check; re-runs when the client is recreated + // (profile switch or connection edit). Stale requests are aborted. + useEffect(() => { + const ctrl = new AbortController(); + setHealth({ state: "unknown" }); + const timer = setTimeout(() => { + client + .health(ctrl.signal) + .then((h) => { + if (!ctrl.signal.aborted) setHealth({ state: "ok", version: h.version }); + }) + .catch((e) => { + if (ctrl.signal.aborted) return; + setHealth({ + state: "failed", + error: e instanceof Error ? e.message : String(e), + }); + }); + }, 200); + return () => { + clearTimeout(timer); + ctrl.abort(); + }; + }, [client]); useEffect(() => { let alive = true; @@ -108,6 +146,14 @@ export default function Header({ : (liveModels ?? MODEL_PRESETS); const providerOptions = [...new Set([...PROVIDERS, ...oauthProviders])]; + const active = activeProfile(profiles); + const dotColor = + health.state === "ok" ? "bg-ok" : health.state === "failed" ? "bg-err" : "bg-muted"; + const indicatorTitle = + health.state === "failed" + ? `connection failed: ${health.error}` + : (active?.baseUrl ?? config.baseUrl); + return ( ☰ - onConfigChange({ baseUrl: e.target.value })} - /> - onConfigChange({ apiKey: e.target.value })} - /> + + setPanelOpen((v) => !v)} + aria-label="connection" + aria-expanded={panelOpen} + title={indicatorTitle} + > + + {active?.name ?? "connection"} + {health.state === "ok" && health.version && ( + v{health.version} + )} + + {panelOpen && ( + setPanelOpen(false)} + /> + )} + { - return (await this.check(await fetch(this.url("/healthz"), { headers: this.headers() }))).json(); + async health(signal?: AbortSignal): Promise<{ status: string; sessions: number; version: string }> { + return (await this.check(await fetch(this.url("/healthz"), { headers: this.headers(), signal }))).json(); } async createSession(opts: { diff --git a/coding-agent/webui/app/src/lib/profiles.test.ts b/coding-agent/webui/app/src/lib/profiles.test.ts new file mode 100644 index 000000000..52b87aa8a --- /dev/null +++ b/coding-agent/webui/app/src/lib/profiles.test.ts @@ -0,0 +1,161 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; +import { + activeProfile, + addProfile, + deriveName, + LEGACY_CONFIG_LS_KEY, + loadProfiles, + PROFILES_LS_KEY, + removeProfile, + setActiveProfile, + updateProfile, + type ProfileStore, +} from "./profiles"; + +function fakeStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => void map.set(key, value), + removeItem: (key: string) => void map.delete(key), + map, + }; +} + +const profile = (id: string, name = id) => ({ + id, + name, + baseUrl: `http://${id}.test`, + apiKey: "", +}); + +describe("loadProfiles", () => { + it("seeds a same-origin active profile when nothing is stored", () => { + const storage = fakeStorage(); + const store = loadProfiles(storage); + + expect(store.profiles).toHaveLength(1); + expect(store.profiles[0].baseUrl).toBe(window.location.origin); + expect(store.profiles[0].apiKey).toBe(""); + expect(store.activeId).toBe(store.profiles[0].id); + // The seed is persisted so the next load is stable. + expect(storage.map.get(PROFILES_LS_KEY)).toBe(JSON.stringify(store)); + }); + + it("migrates the legacy config baseUrl/apiKey into a first profile", () => { + const storage = fakeStorage({ + [LEGACY_CONFIG_LS_KEY]: JSON.stringify({ + baseUrl: "http://127.0.0.1:18080", + apiKey: "secret-key", + }), + }); + const store = loadProfiles(storage); + + expect(store.profiles).toHaveLength(1); + expect(store.profiles[0]).toMatchObject({ + name: "127.0.0.1:18080", + baseUrl: "http://127.0.0.1:18080", + apiKey: "secret-key", + }); + expect(store.activeId).toBe(store.profiles[0].id); + }); + + it("loads a stored store verbatim and repairs a dangling activeId", () => { + const a = profile("a"); + const b = profile("b"); + const storage = fakeStorage({ + [PROFILES_LS_KEY]: JSON.stringify({ profiles: [a, b], activeId: "gone" }), + }); + const store = loadProfiles(storage); + + expect(store.profiles).toEqual([a, b]); + expect(store.activeId).toBe("a"); + }); + + it("reseeds when the stored payload is corrupt", () => { + const storage = fakeStorage({ [PROFILES_LS_KEY]: "{not json" }); + const store = loadProfiles(storage); + + expect(store.profiles).toHaveLength(1); + expect(store.profiles[0].baseUrl).toBe(window.location.origin); + }); + + it("reseeds when the stored store has no usable profiles", () => { + const storage = fakeStorage({ + [PROFILES_LS_KEY]: JSON.stringify({ profiles: [{ name: "no url" }], activeId: "x" }), + }); + const store = loadProfiles(storage); + + expect(store.profiles).toHaveLength(1); + expect(store.profiles[0].baseUrl).toBe(window.location.origin); + }); +}); + +describe("profile CRUD", () => { + const base = (): ProfileStore => ({ + profiles: [profile("a"), profile("b")], + activeId: "a", + }); + + it("adds a profile with a derived name when the name is blank", () => { + const store = addProfile(base(), { + name: " ", + baseUrl: "https://agent.example.com/", + apiKey: "k", + }); + + expect(store.profiles).toHaveLength(3); + const added = store.profiles[2]; + expect(added.name).toBe("agent.example.com"); + expect(added.baseUrl).toBe("https://agent.example.com/"); + expect(added.apiKey).toBe("k"); + // Adding never steals the active slot. + expect(store.activeId).toBe("a"); + }); + + it("updates fields of an existing profile only", () => { + const store = updateProfile(base(), "b", { name: "renamed", apiKey: "k2" }); + + expect(store.profiles[1]).toMatchObject({ id: "b", name: "renamed", apiKey: "k2" }); + expect(store.profiles[0]).toEqual(profile("a")); + }); + + it("switches the active profile", () => { + const store = setActiveProfile(base(), "b"); + + expect(store.activeId).toBe("b"); + expect(activeProfile(store)?.name).toBe("b"); + }); + + it("ignores switching to an unknown id", () => { + const store = setActiveProfile(base(), "nope"); + expect(store.activeId).toBe("a"); + }); + + it("removes an inactive profile", () => { + const store = removeProfile(base(), "b"); + expect(store.profiles.map((p) => p.id)).toEqual(["a"]); + expect(store.activeId).toBe("a"); + }); + + it("refuses to remove the active profile", () => { + expect(() => removeProfile(base(), "a")).toThrow(/active/); + }); + + it("refuses to remove the last profile", () => { + const single: ProfileStore = { profiles: [profile("only")], activeId: "only" }; + expect(() => removeProfile(single, "only")).toThrow(/last/); + }); +}); + +describe("deriveName", () => { + it("uses the URL host", () => { + expect(deriveName("http://localhost:8080/api")).toBe("localhost:8080"); + }); + + it("falls back to 'default' for unparseable input", () => { + expect(deriveName("not a url")).toBe("default"); + }); +}); diff --git a/coding-agent/webui/app/src/lib/profiles.ts b/coding-agent/webui/app/src/lib/profiles.ts new file mode 100644 index 000000000..8778bda08 --- /dev/null +++ b/coding-agent/webui/app/src/lib/profiles.ts @@ -0,0 +1,178 @@ +// Connection profiles: named (baseUrl, apiKey) pairs persisted to +// localStorage, with one active profile driving the app's AgentClient. + +export interface ConnectionProfile { + id: string; + name: string; + baseUrl: string; + apiKey: string; +} + +export interface ProfileStore { + profiles: ConnectionProfile[]; + activeId: string; +} + +export const PROFILES_LS_KEY = "coding-agent-webui-profiles"; +// Pre-profiles config key; its baseUrl/apiKey seed the first profile. +export const LEGACY_CONFIG_LS_KEY = "coding-agent-webui-config"; + +type StorageLike = Pick; + +function defaultStorage(): StorageLike | null { + try { + return typeof localStorage !== "undefined" ? localStorage : null; + } catch { + return null; + } +} + +export function defaultBaseUrl(): string { + const configured = import.meta.env.VITE_API_BASE_URL; + if (typeof configured === "string" && configured.trim()) { + return configured.trim(); + } + return typeof window !== "undefined" ? window.location.origin : ""; +} + +function generateId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `p-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +// Human-friendly default name derived from the URL host ("localhost:8080"). +export function deriveName(baseUrl: string): string { + try { + const host = new URL(baseUrl).host; + if (host) return host; + } catch { + /* fall through */ + } + return "default"; +} + +export function activeProfile(store: ProfileStore): ConnectionProfile | null { + return store.profiles.find((p) => p.id === store.activeId) ?? null; +} + +function sanitizeProfile(raw: unknown): ConnectionProfile | null { + const p = (raw ?? {}) as Record; + if (typeof p.baseUrl !== "string" || !p.baseUrl.trim()) return null; + return { + id: typeof p.id === "string" && p.id ? p.id : generateId(), + name: typeof p.name === "string" && p.name.trim() ? p.name : deriveName(p.baseUrl), + baseUrl: p.baseUrl, + apiKey: typeof p.apiKey === "string" ? p.apiKey : "", + }; +} + +// Seed the initial store: migrate the legacy single-config baseUrl/apiKey +// when present, otherwise a same-origin profile. +function seedStore(storage: StorageLike | null): ProfileStore { + let baseUrl = ""; + let apiKey = ""; + try { + const raw = storage?.getItem(LEGACY_CONFIG_LS_KEY); + if (raw) { + const legacy = JSON.parse(raw) as Record; + if (typeof legacy.baseUrl === "string") baseUrl = legacy.baseUrl; + if (typeof legacy.apiKey === "string") apiKey = legacy.apiKey; + } + } catch { + /* fall through to the same-origin default */ + } + if (!baseUrl.trim()) { + baseUrl = defaultBaseUrl(); + apiKey = ""; + } + const profile: ConnectionProfile = { + id: generateId(), + name: deriveName(baseUrl), + baseUrl, + apiKey, + }; + return { profiles: [profile], activeId: profile.id }; +} + +export function loadProfiles(storage: StorageLike | null = defaultStorage()): ProfileStore { + try { + const raw = storage?.getItem(PROFILES_LS_KEY); + if (raw) { + const parsed = JSON.parse(raw) as { profiles?: unknown; activeId?: unknown }; + const profiles = Array.isArray(parsed.profiles) + ? parsed.profiles.flatMap((p) => { + const profile = sanitizeProfile(p); + return profile ? [profile] : []; + }) + : []; + if (profiles.length > 0) { + const activeId = + typeof parsed.activeId === "string" && + profiles.some((p) => p.id === parsed.activeId) + ? parsed.activeId + : profiles[0].id; + return { profiles, activeId }; + } + } + } catch { + /* corrupt payload — reseed below */ + } + const seeded = seedStore(storage); + saveProfiles(seeded, storage); + return seeded; +} + +export function saveProfiles( + store: ProfileStore, + storage: StorageLike | null = defaultStorage(), +): void { + try { + storage?.setItem(PROFILES_LS_KEY, JSON.stringify(store)); + } catch { + /* ignore */ + } +} + +export function addProfile( + store: ProfileStore, + input: { name: string; baseUrl: string; apiKey: string }, +): ProfileStore { + const profile: ConnectionProfile = { + id: generateId(), + name: input.name.trim() || deriveName(input.baseUrl), + baseUrl: input.baseUrl.trim(), + apiKey: input.apiKey, + }; + return { ...store, profiles: [...store.profiles, profile] }; +} + +export function updateProfile( + store: ProfileStore, + id: string, + patch: Partial<{ name: string; baseUrl: string; apiKey: string }>, +): ProfileStore { + return { + ...store, + profiles: store.profiles.map((p) => (p.id === id ? { ...p, ...patch } : p)), + }; +} + +// Deleting the active or the last profile is rejected — the app always needs +// one active connection. +export function removeProfile(store: ProfileStore, id: string): ProfileStore { + if (!store.profiles.some((p) => p.id === id)) return store; + if (store.profiles.length <= 1) { + throw new Error("cannot delete the last profile"); + } + if (store.activeId === id) { + throw new Error("cannot delete the active profile"); + } + return { ...store, profiles: store.profiles.filter((p) => p.id !== id) }; +} + +export function setActiveProfile(store: ProfileStore, id: string): ProfileStore { + if (!store.profiles.some((p) => p.id === id)) return store; + return { ...store, activeId: id }; +}