+
{t("sidebar.recent")}
-
+
-
+
);
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
new file mode 100644
index 00000000..c24ff97d
--- /dev/null
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -0,0 +1,245 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { ChevronLeft, Loader2 } from "lucide-react";
+
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { fetchSettings, updateSettings } from "@/lib/api";
+import { cn } from "@/lib/utils";
+import { useClient } from "@/providers/ClientProvider";
+import type { SettingsPayload } from "@/lib/types";
+
+interface SettingsViewProps {
+ theme: "light" | "dark";
+ onToggleTheme: () => void;
+ onBackToChat: () => void;
+ onModelNameChange: (modelName: string | null) => void;
+}
+
+export function SettingsView({
+ onBackToChat,
+ onModelNameChange,
+}: SettingsViewProps) {
+ const { token } = useClient();
+ const [settings, setSettings] = useState
(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [form, setForm] = useState({
+ model: "",
+ provider: "auto",
+ });
+
+ const applyPayload = useCallback((payload: SettingsPayload) => {
+ setSettings(payload);
+ setForm({
+ model: payload.agent.model,
+ provider: payload.agent.provider,
+ });
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ fetchSettings(token)
+ .then((payload) => {
+ if (!cancelled) {
+ applyPayload(payload);
+ setError(null);
+ }
+ })
+ .catch((err) => {
+ if (!cancelled) setError((err as Error).message);
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [applyPayload, token]);
+
+ const dirty = useMemo(() => {
+ if (!settings) return false;
+ return (
+ form.model !== settings.agent.model ||
+ form.provider !== settings.agent.provider
+ );
+ }, [form, settings]);
+
+ const save = async () => {
+ if (!dirty || saving) return;
+ setSaving(true);
+ try {
+ const payload = await updateSettings(token, form);
+ applyPayload(payload);
+ onModelNameChange(payload.agent.model || null);
+ setError(null);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+
+ General
+
+ {loading ? (
+
+
+ Loading settings...
+
+ ) : error ? (
+
+
+ {error}
+
+
+ ) : settings ? (
+
+ ) : null}
+
+
+ );
+}
+
+function SettingsSection({
+ form,
+ setForm,
+ settings,
+ dirty,
+ saving,
+ onSave,
+}: {
+ form: {
+ model: string;
+ provider: string;
+ };
+ setForm: React.Dispatch>;
+ settings: SettingsPayload;
+ dirty: boolean;
+ saving: boolean;
+ onSave: () => void;
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+function SettingsGroup({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+function SettingsRow({
+ title,
+ children,
+}: {
+ title: string;
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+ {children ?
{children}
: null}
+
+ );
+}
+
+function SettingsFooter({
+ dirty,
+ saving,
+ saved,
+ onSave,
+}: {
+ dirty: boolean;
+ saving: boolean;
+ saved: boolean;
+ onSave: () => void;
+}) {
+ return (
+
+
+ {saved ? "Saved. Restart nanobot to apply." : "Unsaved changes."}
+
+
+
+ );
+}
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 9012ca0d..56fed32c 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -1,4 +1,4 @@
-import type { ChatSummary } from "./types";
+import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
export class ApiError extends Error {
status: number;
@@ -104,3 +104,21 @@ export async function deleteSession(
);
return body.deleted;
}
+
+export async function fetchSettings(
+ token: string,
+ base: string = "",
+): Promise {
+ return request(`${base}/api/settings`, token);
+}
+
+export async function updateSettings(
+ token: string,
+ update: SettingsUpdate,
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ if (update.model !== undefined) query.set("model", update.model);
+ if (update.provider !== undefined) query.set("provider", update.provider);
+ return request(`${base}/api/settings/update?${query}`, token);
+}
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 96935b09..1b857a17 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -66,6 +66,28 @@ export interface BootstrapResponse {
model_name?: string | null;
}
+export interface SettingsPayload {
+ agent: {
+ model: string;
+ provider: string;
+ resolved_provider: string | null;
+ has_api_key: boolean;
+ };
+ providers: Array<{
+ name: string;
+ label: string;
+ }>;
+ runtime: {
+ config_path: string;
+ };
+ requires_restart: boolean;
+}
+
+export interface SettingsUpdate {
+ model?: string;
+ provider?: string;
+}
+
export type ConnectionStatus =
| "idle"
| "connecting"
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index fdefac77..aab940d5 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { deleteSession, fetchSessionMessages } from "@/lib/api";
+import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -34,4 +34,18 @@ describe("webui API helpers", () => {
}),
);
});
+
+ it("serializes settings updates as a narrow query string", async () => {
+ await updateSettings("tok", {
+ model: "openrouter/test",
+ provider: "openrouter",
+ });
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/update?model=openrouter%2Ftest&provider=openrouter",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
});
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 4c8485b7..77b9420d 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -146,4 +146,44 @@ describe("App layout", () => {
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
+
+ it("opens the Cursor-style settings view from the sidebar", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/settings")) {
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({
+ agent: {
+ model: "openai/gpt-4o",
+ provider: "auto",
+ resolved_provider: "openai",
+ has_api_key: true,
+ },
+ providers: [
+ { name: "auto", label: "Auto" },
+ { name: "openai", label: "OpenAI" },
+ ],
+ runtime: {
+ config_path: "/tmp/config.json",
+ },
+ requires_restart: false,
+ }),
+ };
+ }
+ return { ok: false, status: 404, json: async () => ({}) };
+ }),
+ );
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ fireEvent.click(screen.getByRole("button", { name: "Settings" }));
+
+ expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
+ expect(screen.getByText("AI")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
+ });
});