+
{t("sidebar.recent")}
-
+
-
+
);
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
new file mode 100644
index 000000000..c24ff97da
--- /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/components/thread/AskUserPrompt.tsx b/webui/src/components/thread/AskUserPrompt.tsx
new file mode 100644
index 000000000..3ab20f5e8
--- /dev/null
+++ b/webui/src/components/thread/AskUserPrompt.tsx
@@ -0,0 +1,108 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { MessageSquareText } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface AskUserPromptProps {
+ question: string;
+ buttons: string[][];
+ onAnswer: (answer: string) => void;
+}
+
+export function AskUserPrompt({
+ question,
+ buttons,
+ onAnswer,
+}: AskUserPromptProps) {
+ const [customOpen, setCustomOpen] = useState(false);
+ const [custom, setCustom] = useState("");
+ const inputRef = useRef(null);
+ const options = buttons.flat().filter(Boolean);
+
+ useEffect(() => {
+ if (customOpen) {
+ inputRef.current?.focus();
+ }
+ }, [customOpen]);
+
+ const submitCustom = useCallback(() => {
+ const answer = custom.trim();
+ if (!answer) return;
+ onAnswer(answer);
+ setCustom("");
+ setCustomOpen(false);
+ }, [custom, onAnswer]);
+
+ if (options.length === 0) return null;
+
+ return (
+
+
+
+
+ {options.map((option) => (
+
+ ))}
+
+
+
+ {customOpen ? (
+
+
+ ) : null}
+
+ );
+}
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx
index 0355c47ee..801080bbf 100644
--- a/webui/src/components/thread/ThreadShell.tsx
+++ b/webui/src/components/thread/ThreadShell.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
+import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
@@ -57,6 +58,21 @@ export function ThreadShell({
dismissStreamError,
} = useNanobotStream(chatId, initial);
const showHeroComposer = messages.length === 0 && !loading;
+ const pendingAsk = useMemo(() => {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (message.kind === "trace") continue;
+ if (message.role === "user") return null;
+ if (message.role === "assistant" && message.buttons?.some((row) => row.length > 0)) {
+ return {
+ question: message.content,
+ buttons: message.buttons,
+ };
+ }
+ if (message.role === "assistant") return null;
+ }
+ return null;
+ }, [messages]);
useEffect(() => {
if (!chatId || loading) return;
@@ -152,6 +168,13 @@ export function ThreadShell({
onDismiss={dismissStreamError}
/>
) : null}
+ {pendingAsk ? (
+
+ ) : null}
{session ? (
{
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
+ const content = ev.buttons?.length ? (ev.button_prompt ?? ev.text) : ev.text;
return [
...filtered,
{
id: crypto.randomUUID(),
role: "assistant",
- content: ev.text,
+ content,
createdAt: Date.now(),
+ ...(ev.buttons && ev.buttons.length > 0 ? { buttons: ev.buttons } : {}),
...(media && media.length > 0 ? { media } : {}),
},
];
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 9012ca0dc..56fed32c7 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 245a65bd7..1b857a171 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -44,6 +44,8 @@ export interface UIMessage {
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
media?: UIMediaAttachment[];
+ /** Optional answer choices for a pending ask_user question. */
+ buttons?: string[][];
}
export interface ChatSummary {
@@ -64,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"
@@ -82,6 +106,9 @@ export type InboundEvent =
reply_to?: string;
media?: string[];
media_urls?: Array<{ url: string; name?: string }>;
+ buttons?: string[][];
+ /** Original prompt before the websocket text fallback appends buttons. */
+ button_prompt?: string;
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
* generic progress line) rather than a conversational reply. */
kind?: "tool_hint" | "progress";
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index fdefac77d..aab940d5c 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 4c8485b7f..77b9420dd 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();
+ });
});
diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx
index 9688d6408..d134fcce2 100644
--- a/webui/src/tests/thread-shell.test.tsx
+++ b/webui/src/tests/thread-shell.test.tsx
@@ -7,11 +7,22 @@ import { ClientProvider } from "@/providers/ClientProvider";
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
+ const chatHandlers = new Map void>>();
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
- onChat: () => () => {},
+ onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
+ let handlers = chatHandlers.get(chatId);
+ if (!handlers) {
+ handlers = new Set();
+ chatHandlers.set(chatId, handlers);
+ }
+ handlers.add(handler);
+ return () => {
+ handlers?.delete(handler);
+ };
+ },
onError: (handler: (err: { kind: string }) => void) => {
errorHandlers.add(handler);
return () => {
@@ -21,6 +32,9 @@ function makeClient() {
_emitError(err: { kind: string }) {
for (const h of errorHandlers) h(err);
},
+ _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
+ for (const h of chatHandlers.get(chatId) ?? []) h(ev);
+ },
sendMessage: vi.fn(),
newChat: vi.fn(),
attach: vi.fn(),
@@ -411,4 +425,46 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
});
+
+ it("renders ask_user options above the composer and sends selected answers", async () => {
+ const client = makeClient();
+ const onNewChat = vi.fn().mockResolvedValue("chat-a");
+
+ render(
+ wrap(
+ client,
+ {}}
+ onGoHome={() => {}}
+ onNewChat={onNewChat}
+ />,
+ ),
+ );
+
+ await act(async () => {
+ client._emitChat("chat-a", {
+ event: "message",
+ chat_id: "chat-a",
+ text: "How should I continue?",
+ buttons: [["Short answer", "Detailed answer"]],
+ });
+ });
+
+ expect(screen.getByRole("group", { name: "Question" })).toHaveTextContent(
+ "How should I continue?",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Short answer" }));
+
+ expect(client.sendMessage).toHaveBeenCalledWith(
+ "chat-a",
+ "Short answer",
+ undefined,
+ );
+ await waitFor(() => {
+ expect(screen.queryByRole("group", { name: "Question" })).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 6485980cc..f5adcf176 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -113,4 +113,27 @@ describe("useNanobotStream", () => {
{ kind: "video", url: "/api/media/sig/payload", name: "demo.mp4" },
]);
});
+
+ it("keeps assistant buttons on complete messages", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-q", []), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-q", {
+ event: "message",
+ chat_id: "chat-q",
+ text: "How should I continue?\n\n1. Short answer\n2. Detailed answer",
+ button_prompt: "How should I continue?",
+ buttons: [["Short answer", "Detailed answer"]],
+ });
+ });
+
+ expect(result.current.messages).toHaveLength(1);
+ expect(result.current.messages[0].content).toBe("How should I continue?");
+ expect(result.current.messages[0].buttons).toEqual([
+ ["Short answer", "Detailed answer"],
+ ]);
+ });
});