From 3773623b87e4fa20f4428d3fd30ca9ee72ebb5b8 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Sat, 25 Jul 2026 12:02:41 +0800 Subject: [PATCH] feat: dark/light theme toggle and live provider model list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: new GET /providers/{provider}/models endpoint that lists models through the provider's OpenAI-compatible client (10s timeout, fresh short-lived plugin instance per request, closes its client). Failures (no key, network, non-OpenAI provider) return source=unavailable with an empty list — never 500. Contract doc updated. Webui: light theme overrides for the CSS custom properties with a localStorage-persisted toggle in the header (dark stays default, pre-render init avoids flash). The model input's datalist now fetches live model ids for the selected provider (debounced, stale requests aborted), falling back to the built-in presets when unavailable; input stays free-text. Tests: 8 endpoint tests + 33 vitest (6 new); ruff clean. --- coding-agent/docs/web-client-api-contract.md | 23 ++ .../coding_agent/providers/openai_compat.py | 5 + .../src/coding_agent/server/http_server.py | 38 ++++ .../coding_agent/server/provider_models.py | 42 ++++ .../src/coding_agent/server/schemas.py | 14 ++ coding-agent/tests/ui/test_provider_models.py | 161 ++++++++++++++ coding-agent/webui/app/src/App.test.tsx | 209 ++++++++++++++++++ coding-agent/webui/app/src/App.tsx | 27 +++ .../webui/app/src/components/Header.tsx | 51 ++++- coding-agent/webui/app/src/index.css | 20 ++ coding-agent/webui/app/src/lib/api.ts | 26 +++ coding-agent/webui/app/src/lib/types.ts | 8 + coding-agent/webui/app/src/main.tsx | 8 + 13 files changed, 629 insertions(+), 3 deletions(-) create mode 100644 coding-agent/src/coding_agent/server/provider_models.py create mode 100644 coding-agent/tests/ui/test_provider_models.py diff --git a/coding-agent/docs/web-client-api-contract.md b/coding-agent/docs/web-client-api-contract.md index 8e77fdb7c..bd6e5e6ec 100644 --- a/coding-agent/docs/web-client-api-contract.md +++ b/coding-agent/docs/web-client-api-contract.md @@ -42,6 +42,7 @@ Source of truth in code: | `POST` | `/sessions/{id}/memory/semantic/rebuild` | `{ batch_size, allow_rebuild, confirm_global: true }` → `SemanticMemoryRebuildResponse` | | `GET` | `/sessions/{id}/workspace/diff` | → `{ files[], additions, deletions }` | | `GET` | `/sessions/{id}/workspace/patch` | → `{ format: "unified_diff", patch }` | +| `GET` | `/providers/{provider}/models` | → `ProviderModelsResponse` | | `GET` | `/healthz` | → `{ status, sessions, version }` | `CreateSessionRequest` (all optional unless noted): @@ -83,6 +84,28 @@ context. `topic_count`, `reviewed_memory_count`, `indexed_count`, `skipped_count`, `deleted_count`, `indexed_ids[]`, `deleted_ids[]`). +## Provider model listing + +`GET /providers/{provider}/models` returns the live model list for a provider, +for populating a model picker in the client. `provider` must be one of the +`ProviderName` literals (`openai`, `anthropic`, `copilot`, `kimi`, `kimi-code`, +`kimi-code-anthropic`, `deepseek`, `stepfun`, `codex`); anything else is a +`422`. + +`ProviderModelsResponse`: `{ provider, models: [{ id }], source }` where +`source` is `"live"` when the provider's `GET /models` listing succeeded and +`"unavailable"` otherwise. The endpoint **never fails on provider-side +problems** — missing API key, network error, listing timeout (10s), or a +provider without an OpenAI-compatible listing API (anthropic, codex, +kimi-code-anthropic, copilot) all return `200` with +`{ models: [], source: "unavailable" }`. Clients should treat `"unavailable"` +as "fall back to local presets", not as an error. + +Listing reuses the same provider construction as session runs +(`LLMProviderPlugin.provide_llm`), including env-var API-key fallbacks +(`KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY`, `DEEPSEEK_API_KEY`, `STEP_API_KEY`), +so a provider that works for sessions also works here. + ## Run metadata context pack `GET /sessions/{id}/runs` returns `RuntimeRun[]`; each run's `metadata` dict may diff --git a/coding-agent/src/coding_agent/providers/openai_compat.py b/coding-agent/src/coding_agent/providers/openai_compat.py index dc6805492..a3d0e110d 100644 --- a/coding-agent/src/coding_agent/providers/openai_compat.py +++ b/coding-agent/src/coding_agent/providers/openai_compat.py @@ -263,6 +263,11 @@ async def stream( if last_exception: raise last_exception + async def list_models(self) -> list[str]: + """Return model ids from the provider's ``GET /models`` endpoint.""" + page = await self._client.models.list() + return [model.id for model in page.data] + async def close(self) -> None: if self._http_client: await self._http_client.aclose() diff --git a/coding-agent/src/coding_agent/server/http_server.py b/coding-agent/src/coding_agent/server/http_server.py index c498d00bb..525478daf 100644 --- a/coding-agent/src/coding_agent/server/http_server.py +++ b/coding-agent/src/coding_agent/server/http_server.py @@ -76,6 +76,7 @@ workspace_patch_from_config, workspace_provider_capabilities_from_config, ) +from coding_agent.core.config import ProviderName from coding_agent.executors.external import ExecutorRunRecord, PGExecutorRunStore from coding_agent.observability import ( prometheus_metrics_text, @@ -206,6 +207,7 @@ RuntimeTurnSessionState, run_target_from_dict, ) +from coding_agent.server.provider_models import list_provider_models from coding_agent.server.rate_limit import RateLimits, limiter from coding_agent.server.schemas import ( ApprovalResponseSchema, @@ -226,6 +228,8 @@ PromptRequest, PublishSessionRequest, PublishSessionResponse, + ProviderModelSchema, + ProviderModelsResponse, ReadinessResponse, ResolveInteractionRequest, ResumeSessionRequest, @@ -2456,6 +2460,40 @@ async def readiness_check(request: Request, response: Response) -> ReadinessResp ) +@app.get("/providers/{provider}/models", response_model=ProviderModelsResponse) +@limiter.limit(RateLimits.GET_SESSION) +async def list_provider_models_endpoint( + request: Request, + provider: ProviderName, + api_key: str | None = Depends(verify_api_key), +) -> ProviderModelsResponse: + """List available models for a provider. + + Never fails on provider-side errors (missing API key, network, unsupported + provider): returns ``source="unavailable"`` with an empty model list so the + client can fall back to its presets. + """ + del request, api_key + try: + model_ids = await list_provider_models(provider) + except Exception: + logger.info( + "Provider model listing unavailable provider=%s", + provider, + exc_info=True, + ) + model_ids = [] + if not model_ids: + return ProviderModelsResponse( + provider=provider, models=[], source="unavailable" + ) + return ProviderModelsResponse( + provider=provider, + models=[ProviderModelSchema(id=model_id) for model_id in model_ids], + source="live", + ) + + @app.post("/sessions", response_model=SessionResponse) @limiter.limit(RateLimits.CREATE_SESSION) async def create_session( diff --git a/coding-agent/src/coding_agent/server/provider_models.py b/coding-agent/src/coding_agent/server/provider_models.py new file mode 100644 index 000000000..64d9b6d8f --- /dev/null +++ b/coding-agent/src/coding_agent/server/provider_models.py @@ -0,0 +1,42 @@ +"""Live model listing for providers, used by the HTTP server.""" + +from __future__ import annotations + +import asyncio +import logging + +from coding_agent.core.config import ProviderName +from coding_agent.plugins.llm_provider import LLMProviderPlugin +from coding_agent.providers.openai_compat import OpenAICompatProvider + +logger = logging.getLogger(__name__) + +# Short timeout so a dead provider cannot hang the webui. +LIST_MODELS_TIMEOUT_SECONDS = 10.0 + + +async def list_provider_models( + provider: ProviderName, + *, + timeout: float = LIST_MODELS_TIMEOUT_SECONDS, +) -> list[str]: + """Return model ids for an OpenAI-compatible provider. + + Provider construction reuses ``LLMProviderPlugin.provide_llm`` so base_url + and env-var API-key resolution stay in one place. A fresh plugin instance + is created per call, so the constructed provider (and its httpx client) is + short-lived and is closed here — the plugin-level instance cache is never + shared across requests. + + Returns an empty list when the provider is not OpenAI-compatible + (anthropic, codex, kimi-code-anthropic, copilot). Raises on construction + or network failure; callers decide how to surface that. + """ + plugin = LLMProviderPlugin(provider=provider, model="", api_key="") + instance = plugin.provide_llm() + if not isinstance(instance, OpenAICompatProvider): + return [] + try: + return await asyncio.wait_for(instance.list_models(), timeout=timeout) + finally: + await instance.close() diff --git a/coding-agent/src/coding_agent/server/schemas.py b/coding-agent/src/coding_agent/server/schemas.py index 0e27bbb85..bb7177830 100644 --- a/coding-agent/src/coding_agent/server/schemas.py +++ b/coding-agent/src/coding_agent/server/schemas.py @@ -599,6 +599,20 @@ class ReadinessResponse(BaseModel): checks: dict[str, str] +class ProviderModelSchema(BaseModel): + """One model entry from a provider's live model listing.""" + + id: str + + +class ProviderModelsResponse(BaseModel): + """Response schema for the provider model-listing endpoint.""" + + provider: ProviderName + models: list[ProviderModelSchema] + source: Literal["live", "unavailable"] + + class WorkspaceArchiveResponse(BaseModel): format: Literal["tar.gz"] archive_base64: str diff --git a/coding-agent/tests/ui/test_provider_models.py b/coding-agent/tests/ui/test_provider_models.py new file mode 100644 index 000000000..bbafd1ff1 --- /dev/null +++ b/coding-agent/tests/ui/test_provider_models.py @@ -0,0 +1,161 @@ +"""Tests for the GET /providers/{provider}/models endpoint and its helper.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import patch + +import pytest +from httpx import ASGITransport, AsyncClient + +import coding_agent.server.http_server as http_server +from coding_agent.core.config import settings +from coding_agent.plugins.llm_provider import LLMProviderPlugin +from coding_agent.providers.openai_compat import OpenAICompatProvider +from coding_agent.server.http_server import app +from coding_agent.server.provider_models import list_provider_models +from coding_agent.server.rate_limit import limiter + + +@pytest.fixture +async def client(): + """Create async test client with a fresh rate limiter.""" + limiter.reset() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +class TestProviderModelsEndpoint: + """Endpoint-level tests with the provider client mocked out.""" + + async def test_live_listing_returns_model_ids(self, client, monkeypatch): + async def fake_list_provider_models(provider, *, timeout=10.0): + assert provider == "kimi-code" + return ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"] + + monkeypatch.setattr( + http_server, "list_provider_models", fake_list_provider_models + ) + + response = await client.get("/providers/kimi-code/models") + + assert response.status_code == 200 + assert response.json() == { + "provider": "kimi-code", + "models": [ + {"id": "kimi-for-coding"}, + {"id": "kimi-for-coding-highspeed"}, + {"id": "k3"}, + {"id": "k3-256k"}, + ], + "source": "live", + } + + async def test_unknown_provider_returns_422(self, client): + response = await client.get("/providers/not-a-provider/models") + + assert response.status_code == 422 + + async def test_listing_failure_returns_unavailable(self, client, monkeypatch): + async def failing_list_provider_models(provider, *, timeout=10.0): + raise RuntimeError("no API key configured") + + monkeypatch.setattr( + http_server, "list_provider_models", failing_list_provider_models + ) + + response = await client.get("/providers/deepseek/models") + + assert response.status_code == 200 + assert response.json() == { + "provider": "deepseek", + "models": [], + "source": "unavailable", + } + + async def test_empty_listing_returns_unavailable(self, client, monkeypatch): + async def empty_list_provider_models(provider, *, timeout=10.0): + return [] + + monkeypatch.setattr( + http_server, "list_provider_models", empty_list_provider_models + ) + + response = await client.get("/providers/anthropic/models") + + assert response.status_code == 200 + assert response.json() == { + "provider": "anthropic", + "models": [], + "source": "unavailable", + } + + async def test_requires_api_key_when_configured(self, monkeypatch): + async def fake_list_provider_models(provider, *, timeout=10.0): + return ["k3"] + + monkeypatch.setattr( + http_server, "list_provider_models", fake_list_provider_models + ) + limiter.reset() + with patch.object(settings, "http_api_key", "test-secret-key"): + transport = ASGITransport(app=app) + async with AsyncClient( + transport=transport, base_url="http://test" + ) as authed_client: + unauthenticated = await authed_client.get("/providers/kimi-code/models") + authenticated = await authed_client.get( + "/providers/kimi-code/models", + headers={"X-API-Key": "test-secret-key"}, + ) + + assert unauthenticated.status_code == 401 + assert authenticated.status_code == 200 + assert authenticated.json()["source"] == "live" + + +class TestListProviderModelsHelper: + """Helper-level tests: provider reuse, filtering, timeout, cleanup.""" + + async def test_openai_compatible_provider_returns_ids_and_closes_client( + self, monkeypatch + ): + captured: dict[str, OpenAICompatProvider] = {} + + real_init = OpenAICompatProvider.__init__ + + def tracking_init(self, *args, **kwargs): + real_init(self, *args, **kwargs) + captured["instance"] = self + + async def fake_list_models(self): + return ["kimi-for-coding", "k3"] + + monkeypatch.setattr(OpenAICompatProvider, "__init__", tracking_init) + monkeypatch.setattr(OpenAICompatProvider, "list_models", fake_list_models) + + ids = await list_provider_models("kimi-code") + + assert ids == ["kimi-for-coding", "k3"] + # The short-lived dedicated client is closed after the call. + assert captured["instance"]._http_client is None + + async def test_non_openai_compatible_provider_returns_empty(self, monkeypatch): + sentinel = object() + + monkeypatch.setattr( + LLMProviderPlugin, "provide_llm", lambda self, **kwargs: sentinel + ) + + assert await list_provider_models("anthropic") == [] + + async def test_listing_hits_timeout(self, monkeypatch): + async def slow_list_models(self): + await asyncio.sleep(5) + return ["never"] + + monkeypatch.setattr(OpenAICompatProvider, "list_models", slow_list_models) + + with pytest.raises(asyncio.TimeoutError): + await list_provider_models("openai", timeout=0.05) diff --git a/coding-agent/webui/app/src/App.test.tsx b/coding-agent/webui/app/src/App.test.tsx index f163aac87..95fd5b761 100644 --- a/coding-agent/webui/app/src/App.test.tsx +++ b/coding-agent/webui/app/src/App.test.tsx @@ -1002,3 +1002,212 @@ describe("App session switching", () => { ); }); }); + +function datalistValues(): string[] { + const list = document.getElementById("model-options"); + if (!list) return []; + return Array.from(list.querySelectorAll("option")).map( + (o) => o.getAttribute("value") ?? "", + ); +} + +describe("theme toggle", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "" }), + ); + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.includes("/providers/")) { + return Promise.resolve( + jsonResponse({ provider: "kimi-code", models: [], source: "unavailable" }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + delete document.documentElement.dataset.theme; + }); + + it("defaults to dark and toggles data-theme with persistence", async () => { + render(); + + expect(document.documentElement.dataset.theme).toBe("dark"); + + fireEvent.click(screen.getByRole("button", { name: "toggle theme" })); + expect(document.documentElement.dataset.theme).toBe("light"); + expect(localStorage.getItem("coding-agent-webui-theme")).toBe("light"); + + fireEvent.click(screen.getByRole("button", { name: "toggle theme" })); + expect(document.documentElement.dataset.theme).toBe("dark"); + expect(localStorage.getItem("coding-agent-webui-theme")).toBe("dark"); + }); + + it("restores a persisted light theme on load", async () => { + localStorage.setItem("coding-agent-webui-theme", "light"); + + render(); + + expect(document.documentElement.dataset.theme).toBe("light"); + }); +}); + +describe("provider model list", () => { + beforeEach(() => { + const storage = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + clear: () => storage.clear(), + removeItem: (key: string) => storage.delete(key), + }); + Element.prototype.scrollIntoView = vi.fn(); + localStorage.setItem( + "coding-agent-webui-config", + JSON.stringify({ baseUrl: "http://127.0.0.1:18080", apiKey: "" }), + ); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + vi.restoreAllMocks(); + delete document.documentElement.dataset.theme; + }); + + it("uses live model ids in the datalist when the fetch succeeds", async () => { + let modelsPath = ""; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.endsWith("/providers/kimi-code/models")) { + modelsPath = url; + return Promise.resolve( + jsonResponse({ + provider: "kimi-code", + models: [{ id: "k-live-1" }, { id: "k-live-2" }], + source: "live", + }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]); + await waitFor(() => expect(modelsPath).not.toBe("")); + expect(modelsPath).toBe("http://127.0.0.1:18080/providers/kimi-code/models"); + await waitFor(() => expect(datalistValues()).toEqual(["k-live-1", "k-live-2"])); + // The model input stays free-text (datalist, not a select). + expect(screen.getByTitle("model").tagName).toBe("INPUT"); + }); + + it("keeps the preset datalist when the fetch fails", async () => { + let modelsRequested = false; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.endsWith("/providers/kimi-code/models")) { + modelsRequested = true; + return Promise.resolve(new Response("boom", { status: 500 })); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + await waitFor(() => expect(modelsRequested).toBe(true)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]); + }); + + it("keeps the preset datalist when the server reports source unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.endsWith("/providers/kimi-code/models")) { + return Promise.resolve( + jsonResponse({ provider: "kimi-code", models: [], source: "unavailable" }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + await waitFor(() => + expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]), + ); + // Wait past the debounce + fetch; an unavailable source must not clobber the presets. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]); + }); + + it("does not fetch models when server default provider is selected", async () => { + let modelsRequested = false; + + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/sessions")) { + return Promise.resolve(jsonResponse({ sessions: [] })); + } + if (url.includes("/providers/")) { + modelsRequested = true; + return Promise.resolve( + jsonResponse({ provider: "kimi-code", models: [], source: "unavailable" }), + ); + } + throw new Error(`unexpected fetch ${url}`); + }), + ); + + render(); + + fireEvent.change(screen.getByTitle("provider"), { target: { value: "" } }); + // Wait past the debounce window; no models request may be issued. + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(modelsRequested).toBe(false); + expect(datalistValues()).toEqual(["kimi-for-coding", "k3", "deepseek-chat"]); + }); +}); diff --git a/coding-agent/webui/app/src/App.tsx b/coding-agent/webui/app/src/App.tsx index 299d556b7..714160af7 100644 --- a/coding-agent/webui/app/src/App.tsx +++ b/coding-agent/webui/app/src/App.tsx @@ -28,6 +28,8 @@ import DiffPanel from "./components/DiffPanel"; import MemoryPanel, { extractRecallHits } from "./components/MemoryPanel"; const LS_KEY = "coding-agent-webui-config"; +const THEME_LS_KEY = "coding-agent-webui-theme"; +type Theme = "dark" | "light"; type Config = { baseUrl: string; apiKey: string; @@ -63,8 +65,17 @@ function loadConfig(): Config { return defaults; } +function loadTheme(): Theme { + try { + return localStorage.getItem(THEME_LS_KEY) === "light" ? "light" : "dark"; + } catch { + return "dark"; + } +} + export default function App() { const [config, setConfig] = useState(loadConfig); + const [theme, setTheme] = useState(loadTheme); const [sessionId, setSessionId] = useState(null); const [sessionMode, setSessionMode] = useState<"prompt" | "resume">("prompt"); const [sessions, setSessions] = useState([]); @@ -96,6 +107,19 @@ export default function App() { [config.baseUrl, config.apiKey], ); + useEffect(() => { + document.documentElement.dataset.theme = theme; + try { + localStorage.setItem(THEME_LS_KEY, theme); + } catch { + /* ignore */ + } + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((t) => (t === "dark" ? "light" : "dark")); + }, []); + const patchConfig = useCallback((p: Partial) => { setConfig((c) => { const next = { ...c, ...p }; @@ -461,6 +485,9 @@ export default function App() { onShowDiff={showDiff} sessionId={sessionId} status={status} + client={client} + theme={theme} + onToggleTheme={toggleTheme} />
void; sessionId: string | null; status: string; + client: AgentClient; + theme: "dark" | "light"; + onToggleTheme: () => void; } export default function Header({ @@ -40,7 +46,37 @@ export default function Header({ onShowDiff, sessionId, status, + client, + theme, + onToggleTheme, }: Props) { + // Live model ids for the selected provider; null = use MODEL_PRESETS. + const [liveModels, setLiveModels] = useState(null); + + useEffect(() => { + const provider = config.provider.trim(); + if (!provider) { + setLiveModels(null); + return; + } + const ctrl = new AbortController(); + // Debounce rapid provider switches; the abort drops superseded requests. + const timer = setTimeout(() => { + client + .listProviderModels(provider, ctrl.signal) + .then((res) => { + setLiveModels(res.source === "live" && res.models.length > 0 ? res.models : null); + }) + .catch(() => setLiveModels(null)); + }, 250); + return () => { + clearTimeout(timer); + ctrl.abort(); + }; + }, [client, config.provider]); + + const modelOptions = liveModels ?? MODEL_PRESETS; + return (
onConfigChange({ model: e.target.value })} /> - - {MODEL_PRESETS.map((m) => ( + + {modelOptions.map((m) => ( @@ -114,6 +150,15 @@ export default function Header({ > Diff + {sessionId ? status : "no session"} diff --git a/coding-agent/webui/app/src/index.css b/coding-agent/webui/app/src/index.css index 954d2597c..f242ffac1 100644 --- a/coding-agent/webui/app/src/index.css +++ b/coding-agent/webui/app/src/index.css @@ -15,9 +15,29 @@ --color-warn: #d29922; } +/* Light theme overrides the same custom properties; applied when + is set (default is dark). */ +:root[data-theme="light"] { + --color-surface-0: #f5f6f8; + --color-surface-1: #ffffff; + --color-surface-2: #e9ecf1; + --color-border: #d3d7de; + --color-border-active: #9aa3b2; + --color-fg: #1c1f27; + --color-muted: #5f6678; + --color-accent: #2f6bd8; + --color-accent-hover: #255cb8; + --color-ok: #1a7f37; + --color-err: #cf222e; + --color-warn: #9a6700; +} + html { color-scheme: dark; } +html[data-theme="light"] { + color-scheme: light; +} body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; diff --git a/coding-agent/webui/app/src/lib/api.ts b/coding-agent/webui/app/src/lib/api.ts index ba8c5123c..730827eb7 100644 --- a/coding-agent/webui/app/src/lib/api.ts +++ b/coding-agent/webui/app/src/lib/api.ts @@ -4,6 +4,7 @@ import type { ApprovalScope, DisplayStreamEvent, MemoryReviewRecord, + ProviderModels, RuntimeRun, SessionSummary, SessionResult, @@ -219,6 +220,31 @@ export class AgentClient { return Array.isArray(data) ? (data as MemoryReviewRecord[]) : []; } + // GET /providers/{provider}/models. Unknown providers return 422 (throws); + // provider-side failures return 200 with source="unavailable" and models=[]. + async listProviderModels(provider: string, signal?: AbortSignal): Promise { + const r = await this.check( + await fetch(this.url(`/providers/${encodeURIComponent(provider)}/models`), { + headers: this.headers(), + signal, + }), + ); + // Guard against malformed payloads: coerce to a safe shape instead of trusting it. + const data: unknown = await r.json().catch(() => null); + const raw = (data ?? {}) as { provider?: unknown; models?: unknown; source?: unknown }; + const models = Array.isArray(raw.models) + ? raw.models.flatMap((m) => { + const id = (m as { id?: unknown } | null)?.id; + return typeof id === "string" && id.trim() ? [id] : []; + }) + : []; + return { + provider: typeof raw.provider === "string" ? raw.provider : provider, + models, + source: raw.source === "live" ? "live" : "unavailable", + }; + } + private async runDisplayEvents(runId: string): Promise { const events: DisplayStreamEvent[] = []; let lastEventId: string | null = null; diff --git a/coding-agent/webui/app/src/lib/types.ts b/coding-agent/webui/app/src/lib/types.ts index fceb79912..6afc008e7 100644 --- a/coding-agent/webui/app/src/lib/types.ts +++ b/coding-agent/webui/app/src/lib/types.ts @@ -230,3 +230,11 @@ export interface WorkspacePatch { format: "unified_diff"; patch: string; } + +// GET /providers/{provider}/models — ids are normalized to plain strings by +// the client; source="unavailable" means the provider could not be queried. +export interface ProviderModels { + provider: string; + models: string[]; + source: "live" | "unavailable"; +} diff --git a/coding-agent/webui/app/src/main.tsx b/coding-agent/webui/app/src/main.tsx index c2a145c6d..24ec49cbf 100644 --- a/coding-agent/webui/app/src/main.tsx +++ b/coding-agent/webui/app/src/main.tsx @@ -3,6 +3,14 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import "./index.css"; +// Apply the persisted theme before first paint to avoid a flash of the wrong theme. +try { + const stored = localStorage.getItem("coding-agent-webui-theme"); + document.documentElement.dataset.theme = stored === "light" ? "light" : "dark"; +} catch { + document.documentElement.dataset.theme = "dark"; +} + createRoot(document.getElementById("root")!).render(