+
- {saved ? t("settings.status.savedRestart") : t("settings.status.unsaved")}
+
+ {statusMessage}
+
+
+
+
-
+
+ );
+}
+
+function SettingsStatusMessage({
+ children,
+ tone,
+}: {
+ children?: ReactNode;
+ tone?: "accent" | "danger";
+}) {
+ if (!children) return null;
+ return (
+
+ {tone ? (
+
+ ) : null}
+ {children}
+
+ );
+}
+
+function StatusPill({
+ children,
+ tone = "neutral",
+}: {
+ children: ReactNode;
+ tone?: "neutral" | "success" | "warning";
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SegmentedControl({
+ value,
+ options,
+ onChange,
+}: {
+ value: string;
+ options: Array<{ value: string; label: string }>;
+ onChange: (value: string) => void;
+}) {
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+function ToggleButton({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (checked: boolean) => void;
+ label: string;
+}) {
+ return (
+
+ );
+}
+
+function NumberInput({
+ value,
+ min,
+ max,
+ onChange,
+ suffix,
+}: {
+ value: number;
+ min: number;
+ max: number;
+ onChange: (value: number) => void;
+ suffix?: string;
+}) {
+ return (
+
+ {
+ const parsed = Number(event.target.value);
+ if (Number.isFinite(parsed)) onChange(parsed);
+ }}
+ className="h-8 w-24 rounded-full text-[13px]"
+ />
+ {suffix ? {suffix} : null}
);
}
diff --git a/webui/src/components/ui/dialog.tsx b/webui/src/components/ui/dialog.tsx
index d4ed442c..52792bab 100644
--- a/webui/src/components/ui/dialog.tsx
+++ b/webui/src/components/ui/dialog.tsx
@@ -24,26 +24,35 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+interface DialogContentProps
+ extends React.ComponentPropsWithoutRef
{
+ showCloseButton?: boolean;
+}
+
const DialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
+ DialogContentProps
+>(({ className, children, showCloseButton = true, ...props }, ref) => (
-
- {children}
-
-
- Close
-
-
+
+
+ {children}
+ {showCloseButton ? (
+
+
+ Close
+
+ ) : null}
+
+
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
diff --git a/webui/src/components/ui/dropdown-menu.tsx b/webui/src/components/ui/dropdown-menu.tsx
index bd32944e..cc5ef6ee 100644
--- a/webui/src/components/ui/dropdown-menu.tsx
+++ b/webui/src/components/ui/dropdown-menu.tsx
@@ -47,11 +47,16 @@ const DropdownMenuSubContent = React.forwardRef<
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
+interface DropdownMenuContentProps
+ extends React.ComponentPropsWithoutRef {
+ portalContainer?: HTMLElement | null;
+}
+
const DropdownMenuContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
+ DropdownMenuContentProps
+>(({ className, sideOffset = 4, portalContainer, ...props }, ref) => (
+
();
+ for (const item of value) {
+ if (typeof item !== "string") continue;
+ const cleaned = item.trim();
+ if (!cleaned || seen.has(cleaned)) continue;
+ seen.add(cleaned);
+ out.push(cleaned);
+ }
+ return out;
+}
+
+function stringMap(value: unknown): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const out: Record = {};
+ for (const [key, raw] of Object.entries(value)) {
+ if (typeof raw !== "string") continue;
+ const cleanedKey = key.trim();
+ const cleanedValue = raw.trim();
+ if (!cleanedKey || !cleanedValue) continue;
+ out[cleanedKey] = cleanedValue;
+ }
+ return out;
+}
+
+function tagsMap(value: unknown): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const out: Record = {};
+ for (const [key, raw] of Object.entries(value)) {
+ const cleanedKey = key.trim();
+ if (!cleanedKey) continue;
+ const tags = uniqueStrings(raw).slice(0, 12);
+ if (tags.length) out[cleanedKey] = tags;
+ }
+ return out;
+}
+
+function boolMap(value: unknown): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
+ const out: Record = {};
+ for (const [key, raw] of Object.entries(value)) {
+ const cleanedKey = key.trim();
+ if (cleanedKey) out[cleanedKey] = Boolean(raw);
+ }
+ return out;
+}
+
+export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
+ return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
+ }
+ const value = raw as Partial;
+ const view = value.view && typeof value.view === "object"
+ ? value.view
+ : DEFAULT_SIDEBAR_STATE.view;
+ const density = view.density === "compact" ? "compact" : "comfortable";
+ const sort = ["updated_desc", "created_desc", "title_asc"].includes(view.sort)
+ ? view.sort
+ : "updated_desc";
+ return {
+ schema_version: 1,
+ pinned_keys: uniqueStrings(value.pinned_keys),
+ archived_keys: uniqueStrings(value.archived_keys),
+ title_overrides: stringMap(value.title_overrides),
+ tags_by_key: tagsMap(value.tags_by_key),
+ collapsed_groups: boolMap(value.collapsed_groups),
+ view: {
+ density,
+ show_previews: Boolean(view.show_previews),
+ show_timestamps: Boolean(view.show_timestamps),
+ show_archived: Boolean(view.show_archived),
+ sort,
+ },
+ updated_at: typeof value.updated_at === "string" ? value.updated_at : null,
+ };
+}
+
+function pruneMissingSessions(
+ state: SidebarStatePayload,
+ sessions: ChatSummary[],
+): SidebarStatePayload {
+ const valid = new Set(sessions.map((session) => session.key));
+ const filterKeys = (keys: string[]) => keys.filter((key) => valid.has(key));
+ const filterMap = (map: Record): Record => {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(map)) {
+ if (valid.has(key)) out[key] = value;
+ }
+ return out;
+ };
+ return {
+ ...state,
+ pinned_keys: filterKeys(state.pinned_keys),
+ archived_keys: filterKeys(state.archived_keys),
+ title_overrides: filterMap(state.title_overrides),
+ tags_by_key: filterMap(state.tags_by_key),
+ };
+}
+
+function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+export function useSidebarState(
+ sessions: ChatSummary[],
+ sessionsLoaded: boolean,
+): {
+ state: SidebarStatePayload;
+ loading: boolean;
+ update: (
+ updater: (state: SidebarStatePayload) => SidebarStatePayload,
+ ) => Promise;
+} {
+ const { token } = useClient();
+ const tokenRef = useRef(token);
+ const stateRef = useRef(DEFAULT_SIDEBAR_STATE);
+ const persistVersionRef = useRef(0);
+ const [state, setState] = useState(DEFAULT_SIDEBAR_STATE);
+ const [loading, setLoading] = useState(true);
+ tokenRef.current = token;
+ stateRef.current = state;
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ (async () => {
+ try {
+ const loaded = normalizeSidebarState(await fetchSidebarState(tokenRef.current));
+ if (cancelled) return;
+ stateRef.current = loaded;
+ setState(loaded);
+ } catch {
+ if (cancelled) return;
+ stateRef.current = DEFAULT_SIDEBAR_STATE;
+ setState(DEFAULT_SIDEBAR_STATE);
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const update = useCallback(
+ async (updater: (current: SidebarStatePayload) => SidebarStatePayload) => {
+ const next = normalizeSidebarState(updater(stateRef.current));
+ const version = persistVersionRef.current + 1;
+ persistVersionRef.current = version;
+ stateRef.current = next;
+ setState(next);
+ try {
+ const persisted = normalizeSidebarState(
+ await persistSidebarState(tokenRef.current, next),
+ );
+ if (persistVersionRef.current !== version) return;
+ stateRef.current = persisted;
+ setState(persisted);
+ } catch {
+ // Keep the optimistic UI state. Older gateways or transient auth expiry
+ // should not break the chat list; the next refresh can try again.
+ }
+ },
+ [],
+ );
+
+ const pruned = useMemo(() => {
+ if (!sessionsLoaded || loading) return state;
+ return pruneMissingSessions(state, sessions);
+ }, [loading, sessions, sessionsLoaded, state]);
+
+ useEffect(() => {
+ if (!sessionsLoaded || loading || sameState(pruned, state)) return;
+ void update(() => pruned);
+ }, [loading, pruned, sessionsLoaded, state, update]);
+
+ return { state, loading, update };
+}
diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json
index 38dc79d7..f44332f9 100644
--- a/webui/src/i18n/locales/en/common.json
+++ b/webui/src/i18n/locales/en/common.json
@@ -45,8 +45,16 @@
"toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat",
- "searchAria": "Search chats",
- "searchPlaceholder": "Search chats",
+ "searchAria": "Search",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
+ "searchPlaceholder": "Search",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent",
@@ -65,12 +73,31 @@
},
"nav": {
"general": "General",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "Interface",
"ai": "AI",
- "system": "System"
+ "system": "System",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "imageGeneration": "Image generation",
+ "imageDefaults": "Defaults",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "capabilities": "Capabilities",
+ "integrations": "Integrations"
},
"rows": {
"theme": "Theme",
@@ -78,31 +105,104 @@
"provider": "Provider",
"model": "Model",
"restart": "Restart nanobot",
- "configPath": "Config path"
+ "configPath": "Config path",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "pendingChanges": "Pending changes",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "imageGeneration": "Image generation",
+ "imageProvider": "Image provider",
+ "imageProviderStatus": "Provider status",
+ "imageProviderBase": "Provider base",
+ "imageModel": "Image model",
+ "defaultAspectRatio": "Default aspect",
+ "defaultImageSize": "Default size",
+ "maxImagesPerTurn": "Max images per turn",
+ "imageSaveDir": "Save directory",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "Switch between light and dark appearance.",
"language": "Choose the language used by the WebUI.",
"provider": "Select the provider that should serve new model requests.",
"model": "Set the default model name used by nanobot.",
- "configPath": "The gateway configuration file currently in use."
+ "configPath": "The gateway configuration file currently in use.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "imageGeneration": "Expose generate_image in chats when a configured image provider is available.",
+ "imageProvider": "Choose the registry provider used by generate_image.",
+ "imageProviderStatus": "Image generation reuses provider credentials from Providers.",
+ "imageModel": "Model name sent to the selected image provider.",
+ "defaultAspectRatio": "Used when the prompt does not choose an aspect ratio.",
+ "defaultImageSize": "Size hint sent to providers that support it.",
+ "maxImagesPerTurn": "Upper bound for one generate_image request.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Light",
"dark": "Dark",
- "notAvailable": "Not available"
+ "notAvailable": "Not available",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartPending": "Restart pending",
+ "ready": "Ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Loading settings...",
"loadError": "Could not load settings",
"unsaved": "Unsaved changes.",
- "savedRestart": "Saved. Restart nanobot to apply."
+ "upToDate": "Up to date.",
+ "savedRestart": "Saved. Restart nanobot to apply.",
+ "restartAfterSaving": "Save changes, then restart when ready.",
+ "savedRestartApply": "Saved. Restart when ready.",
+ "imageProviderRestart": "Image provider changes saved. Restart when ready."
},
"actions": {
"save": "Save",
"saving": "Saving",
"edit": "Edit",
- "cancel": "Cancel"
+ "cancel": "Cancel",
+ "openDocs": "Open docs"
},
"byok": {
"description": "Bring your own provider keys. Nanobot reads these values from the current config and only configured providers can be selected in General.",
@@ -145,6 +245,26 @@
"missingCredential": "Add the required credential before saving.",
"saveHint": "Changes apply to new web search requests."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "imageGeneration": "Image generation",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
+ },
+ "image": {
+ "selectProvider": "Select provider",
+ "selectAspect": "Select aspect",
+ "selectSize": "Select size",
+ "configureProvider": "Configure provider",
+ "missingCredential": "Configure this provider before enabling image generation."
}
},
"chat": {
@@ -152,12 +272,30 @@
"loading": "Loading…",
"noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "Delete",
"newChat": "New chat",
"groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
"today": "Today",
"yesterday": "Yesterday",
- "earlier": "Earlier"
+ "earlier": "Earlier",
+ "archived": "Archived"
}
},
"deleteConfirm": {
diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json
index 86df20b3..e658803e 100644
--- a/webui/src/i18n/locales/es/common.json
+++ b/webui/src/i18n/locales/es/common.json
@@ -30,13 +30,25 @@
"collapse": "Contraer barra lateral",
"toggleTheme": "Cambiar tema",
"newChat": "Nuevo chat",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "Recientes",
"refreshSessions": "Actualizar sesiones",
"settings": "Configuración",
"language": {
"label": "Idioma",
"ariaLabel": "Cambiar idioma"
- }
+ },
+ "searchAria": "Buscar",
+ "searchPlaceholder": "Buscar",
+ "searchResults": "Resultados",
+ "noSearchResults": "No hay chats coincidentes."
},
"settings": {
"backToChat": "Volver al chat",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "General",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "Interfaz",
"ai": "IA",
- "system": "Sistema"
+ "system": "Sistema",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "Tema",
@@ -59,19 +87,70 @@
"provider": "Proveedor",
"model": "Modelo",
"restart": "Reiniciar nanobot",
- "configPath": "Ruta de configuración"
+ "configPath": "Ruta de configuración",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "Cambia entre apariencia clara y oscura.",
"language": "Elige el idioma usado por la WebUI.",
"provider": "Selecciona el proveedor para nuevas solicitudes de modelo.",
"model": "Define el nombre del modelo predeterminado que usa nanobot.",
- "configPath": "El archivo de configuración que usa actualmente el gateway."
+ "configPath": "El archivo de configuración que usa actualmente el gateway.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Claro",
"dark": "Oscuro",
- "notAvailable": "No disponible"
+ "notAvailable": "No disponible",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Cargando configuración...",
@@ -83,7 +162,8 @@
"save": "Guardar",
"saving": "Guardando",
"edit": "Editar",
- "cancel": "Cancelar"
+ "cancel": "Cancelar",
+ "openDocs": "Open docs"
},
"byok": {
"description": "Usa tus propias claves de proveedor. Nanobot lee estos valores desde la configuración actual, y solo los proveedores configurados se pueden elegir en General.",
@@ -126,6 +206,18 @@
"missingCredential": "Añade la credencial requerida antes de guardar.",
"saveHint": "Los cambios se aplican a nuevas solicitudes de web search."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "Cargando…",
"noSessions": "Todavía no hay sesiones.",
"actions": "Acciones del chat {{title}}",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "Eliminar",
- "newChat": "Nuevo chat"
+ "newChat": "Nuevo chat",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "¿Eliminar este chat?",
diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json
index aa2b6214..a6f80e72 100644
--- a/webui/src/i18n/locales/fr/common.json
+++ b/webui/src/i18n/locales/fr/common.json
@@ -30,13 +30,25 @@
"collapse": "Réduire la barre latérale",
"toggleTheme": "Changer de thème",
"newChat": "Nouvelle discussion",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "Récentes",
"refreshSessions": "Actualiser les sessions",
"settings": "Paramètres",
"language": {
"label": "Langue",
"ariaLabel": "Changer de langue"
- }
+ },
+ "searchAria": "Rechercher",
+ "searchPlaceholder": "Rechercher",
+ "searchResults": "Résultats",
+ "noSearchResults": "Aucun chat correspondant."
},
"settings": {
"backToChat": "Retour à la discussion",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "Général",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "Interface",
"ai": "IA",
- "system": "Système"
+ "system": "Système",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "Thème",
@@ -59,19 +87,70 @@
"provider": "Fournisseur",
"model": "Modèle",
"restart": "Redémarrer nanobot",
- "configPath": "Chemin de configuration"
+ "configPath": "Chemin de configuration",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "Basculer entre les apparences claire et sombre.",
"language": "Choisissez la langue utilisée par le WebUI.",
"provider": "Sélectionnez le fournisseur des nouvelles requêtes de modèle.",
"model": "Définissez le nom du modèle par défaut utilisé par nanobot.",
- "configPath": "Le fichier de configuration actuellement utilisé par la passerelle."
+ "configPath": "Le fichier de configuration actuellement utilisé par la passerelle.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Clair",
"dark": "Sombre",
- "notAvailable": "Indisponible"
+ "notAvailable": "Indisponible",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Chargement des paramètres...",
@@ -83,7 +162,8 @@
"save": "Enregistrer",
"saving": "Enregistrement",
"edit": "Modifier",
- "cancel": "Annuler"
+ "cancel": "Annuler",
+ "openDocs": "Open docs"
},
"byok": {
"description": "Utilisez vos propres clés de fournisseur. Nanobot lit ces valeurs depuis la configuration actuelle, et seuls les fournisseurs configurés peuvent être sélectionnés dans Général.",
@@ -126,6 +206,18 @@
"missingCredential": "Ajoutez l'identifiant requis avant d'enregistrer.",
"saveHint": "Les changements s'appliquent aux nouvelles requêtes web search."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "Chargement…",
"noSessions": "Aucune session pour le moment.",
"actions": "Actions de la discussion {{title}}",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "Supprimer",
- "newChat": "Nouvelle discussion"
+ "newChat": "Nouvelle discussion",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "Supprimer cette discussion ?",
diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json
index 062a3573..e64db602 100644
--- a/webui/src/i18n/locales/id/common.json
+++ b/webui/src/i18n/locales/id/common.json
@@ -30,13 +30,25 @@
"collapse": "Ciutkan sidebar",
"toggleTheme": "Ganti tema",
"newChat": "Obrolan baru",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "Terbaru",
"refreshSessions": "Segarkan sesi",
"settings": "Pengaturan",
"language": {
"label": "Bahasa",
"ariaLabel": "Ganti bahasa"
- }
+ },
+ "searchAria": "Cari",
+ "searchPlaceholder": "Cari",
+ "searchResults": "Hasil",
+ "noSearchResults": "Tidak ada chat yang cocok."
},
"settings": {
"backToChat": "Kembali ke obrolan",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "Umum",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "Antarmuka",
"ai": "AI",
- "system": "Sistem"
+ "system": "Sistem",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "Tema",
@@ -59,19 +87,70 @@
"provider": "Penyedia",
"model": "Model",
"restart": "Mulai ulang nanobot",
- "configPath": "Path konfigurasi"
+ "configPath": "Path konfigurasi",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "Beralih antara tampilan terang dan gelap.",
"language": "Pilih bahasa yang digunakan WebUI.",
"provider": "Pilih penyedia untuk permintaan model baru.",
"model": "Atur nama model default yang digunakan nanobot.",
- "configPath": "File konfigurasi gateway yang sedang digunakan."
+ "configPath": "File konfigurasi gateway yang sedang digunakan.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Terang",
"dark": "Gelap",
- "notAvailable": "Tidak tersedia"
+ "notAvailable": "Tidak tersedia",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Memuat pengaturan...",
@@ -83,7 +162,8 @@
"save": "Simpan",
"saving": "Menyimpan",
"edit": "Edit",
- "cancel": "Batal"
+ "cancel": "Batal",
+ "openDocs": "Open docs"
},
"byok": {
"description": "Gunakan kunci provider Anda sendiri. Nanobot membaca nilai ini dari config saat ini, dan hanya provider yang sudah dikonfigurasi yang bisa dipilih di Umum.",
@@ -126,6 +206,18 @@
"missingCredential": "Tambahkan kredensial yang diperlukan sebelum menyimpan.",
"saveHint": "Perubahan berlaku untuk permintaan web search baru."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "Memuat…",
"noSessions": "Belum ada sesi.",
"actions": "Aksi obrolan untuk {{title}}",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "Hapus",
- "newChat": "Obrolan baru"
+ "newChat": "Obrolan baru",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "Hapus obrolan ini?",
diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json
index 225c9d8b..c1bacd12 100644
--- a/webui/src/i18n/locales/ja/common.json
+++ b/webui/src/i18n/locales/ja/common.json
@@ -30,13 +30,25 @@
"collapse": "サイドバーを閉じる",
"toggleTheme": "テーマを切り替える",
"newChat": "新しいチャット",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "最近のチャット",
"refreshSessions": "セッションを更新",
"settings": "設定",
"language": {
"label": "言語",
"ariaLabel": "言語を変更"
- }
+ },
+ "searchAria": "検索",
+ "searchPlaceholder": "検索",
+ "searchResults": "検索結果",
+ "noSearchResults": "一致するチャットはありません。"
},
"settings": {
"backToChat": "チャットに戻る",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "一般",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "インターフェース",
"ai": "AI",
- "system": "システム"
+ "system": "システム",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "テーマ",
@@ -59,19 +87,70 @@
"provider": "プロバイダー",
"model": "モデル",
"restart": "nanobot を再起動",
- "configPath": "設定パス"
+ "configPath": "設定パス",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "ライト表示とダーク表示を切り替えます。",
"language": "WebUI で使用する言語を選択します。",
"provider": "新しいモデルリクエストに使うプロバイダーを選択します。",
"model": "nanobot が既定で使用するモデル名を設定します。",
- "configPath": "現在ゲートウェイが使用している設定ファイルです。"
+ "configPath": "現在ゲートウェイが使用している設定ファイルです。",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "ライト",
"dark": "ダーク",
- "notAvailable": "利用不可"
+ "notAvailable": "利用不可",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "設定を読み込んでいます...",
@@ -83,7 +162,8 @@
"save": "保存",
"saving": "保存中",
"edit": "編集",
- "cancel": "キャンセル"
+ "cancel": "キャンセル",
+ "openDocs": "Open docs"
},
"byok": {
"description": "自分の provider キーを使います。Nanobot は現在の config から値を読み込み、設定済みの provider だけを一般設定で選択できます。",
@@ -126,6 +206,18 @@
"missingCredential": "保存する前に必要な認証情報を入力してください。",
"saveHint": "変更は新しい web search リクエストに適用されます。"
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "読み込み中…",
"noSessions": "まだセッションがありません。",
"actions": "「{{title}}」のチャット操作",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "削除",
- "newChat": "新しいチャット"
+ "newChat": "新しいチャット",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "このチャットを削除しますか?",
diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json
index c3b03860..f9493664 100644
--- a/webui/src/i18n/locales/ko/common.json
+++ b/webui/src/i18n/locales/ko/common.json
@@ -30,13 +30,25 @@
"collapse": "사이드바 접기",
"toggleTheme": "테마 전환",
"newChat": "새 채팅",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "최근 대화",
"refreshSessions": "세션 새로고침",
"settings": "설정",
"language": {
"label": "언어",
"ariaLabel": "언어 변경"
- }
+ },
+ "searchAria": "검색",
+ "searchPlaceholder": "검색",
+ "searchResults": "결과",
+ "noSearchResults": "일치하는 채팅이 없습니다."
},
"settings": {
"backToChat": "채팅으로 돌아가기",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "일반",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "인터페이스",
"ai": "AI",
- "system": "시스템"
+ "system": "시스템",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "테마",
@@ -59,19 +87,70 @@
"provider": "제공자",
"model": "모델",
"restart": "nanobot 재시작",
- "configPath": "설정 경로"
+ "configPath": "설정 경로",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "밝은 모드와 어두운 모드를 전환합니다.",
"language": "WebUI에서 사용할 언어를 선택합니다.",
"provider": "새 모델 요청에 사용할 제공자를 선택합니다.",
"model": "nanobot이 기본으로 사용할 모델 이름을 설정합니다.",
- "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다."
+ "configPath": "현재 게이트웨이가 사용하는 설정 파일입니다.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "라이트",
"dark": "다크",
- "notAvailable": "사용할 수 없음"
+ "notAvailable": "사용할 수 없음",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "설정을 불러오는 중...",
@@ -83,7 +162,8 @@
"save": "저장",
"saving": "저장 중",
"edit": "편집",
- "cancel": "취소"
+ "cancel": "취소",
+ "openDocs": "Open docs"
},
"byok": {
"description": "직접 provider 키를 가져옵니다. Nanobot은 현재 config에서 값을 읽고, 설정된 provider만 일반 설정에서 선택할 수 있습니다.",
@@ -126,6 +206,18 @@
"missingCredential": "저장하기 전에 필요한 자격 증명을 입력하세요.",
"saveHint": "변경 사항은 새 web search 요청에 적용됩니다."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "불러오는 중…",
"noSessions": "아직 세션이 없습니다.",
"actions": "{{title}} 채팅 작업",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "삭제",
- "newChat": "새 채팅"
+ "newChat": "새 채팅",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "이 채팅을 삭제할까요?",
diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json
index 9efbcd77..805e82ce 100644
--- a/webui/src/i18n/locales/vi/common.json
+++ b/webui/src/i18n/locales/vi/common.json
@@ -30,13 +30,25 @@
"collapse": "Thu gọn thanh bên",
"toggleTheme": "Chuyển giao diện",
"newChat": "Cuộc trò chuyện mới",
+ "viewOptions": "View",
+ "compactList": "Compact list",
+ "showPreviews": "Show previews",
+ "showTimestamps": "Show time",
+ "sortLabel": "Sort",
+ "sortUpdated": "Recently updated",
+ "sortCreated": "Recently created",
+ "sortTitle": "Title A-Z",
"recent": "Gần đây",
"refreshSessions": "Làm mới phiên",
"settings": "Cài đặt",
"language": {
"label": "Ngôn ngữ",
"ariaLabel": "Đổi ngôn ngữ"
- }
+ },
+ "searchAria": "Tìm kiếm",
+ "searchPlaceholder": "Tìm kiếm",
+ "searchResults": "Kết quả",
+ "noSearchResults": "Không có cuộc trò chuyện phù hợp."
},
"settings": {
"backToChat": "Quay lại trò chuyện",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "Chung",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "Giao diện",
"ai": "AI",
- "system": "Hệ thống"
+ "system": "Hệ thống",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "Giao diện",
@@ -59,19 +87,70 @@
"provider": "Nhà cung cấp",
"model": "Mô hình",
"restart": "Khởi động lại nanobot",
- "configPath": "Đường dẫn cấu hình"
+ "configPath": "Đường dẫn cấu hình",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "Chuyển giữa giao diện sáng và tối.",
"language": "Chọn ngôn ngữ dùng trong WebUI.",
"provider": "Chọn nhà cung cấp cho các yêu cầu mô hình mới.",
"model": "Đặt tên mô hình mặc định mà nanobot sử dụng.",
- "configPath": "Tệp cấu hình gateway hiện đang dùng."
+ "configPath": "Tệp cấu hình gateway hiện đang dùng.",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "Sáng",
"dark": "Tối",
- "notAvailable": "Không khả dụng"
+ "notAvailable": "Không khả dụng",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "Đang tải cài đặt...",
@@ -83,7 +162,8 @@
"save": "Lưu",
"saving": "Đang lưu",
"edit": "Sửa",
- "cancel": "Hủy"
+ "cancel": "Hủy",
+ "openDocs": "Open docs"
},
"byok": {
"description": "Dùng key provider của riêng bạn. Nanobot đọc các giá trị này từ config hiện tại, và chỉ provider đã cấu hình mới có thể chọn trong Chung.",
@@ -126,6 +206,18 @@
"missingCredential": "Thêm thông tin bắt buộc trước khi lưu.",
"saveHint": "Thay đổi áp dụng cho các yêu cầu web search mới."
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "Đang tải…",
"noSessions": "Chưa có phiên nào.",
"actions": "Tác vụ cho cuộc trò chuyện {{title}}",
+ "activity": {
+ "running": "Agent running",
+ "complete": "Agent finished"
+ },
+ "pin": "Pin",
+ "unpin": "Unpin",
+ "rename": "Rename",
+ "renameTitle": "Rename chat",
+ "renameDescription": "Choose a local sidebar name for this chat.",
+ "renamePlaceholder": "Chat name",
+ "renameSave": "Save",
+ "archive": "Archive",
+ "unarchive": "Unarchive",
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
"delete": "Xóa",
- "newChat": "Cuộc trò chuyện mới"
+ "newChat": "Cuộc trò chuyện mới",
+ "groups": {
+ "pinned": "Pinned",
+ "all": "Chats",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "earlier": "Earlier",
+ "archived": "Archived"
+ }
},
"deleteConfirm": {
"title": "Xóa cuộc trò chuyện này?",
diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json
index 6f1a4be2..18089c7a 100644
--- a/webui/src/i18n/locales/zh-CN/common.json
+++ b/webui/src/i18n/locales/zh-CN/common.json
@@ -33,8 +33,16 @@
"toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话",
- "searchAria": "搜索会话",
- "searchPlaceholder": "搜索会话",
+ "searchAria": "搜索",
+ "viewOptions": "视图",
+ "compactList": "紧凑列表",
+ "showPreviews": "显示预览",
+ "showTimestamps": "显示时间",
+ "sortLabel": "排序",
+ "sortUpdated": "最近更新",
+ "sortCreated": "最近创建",
+ "sortTitle": "标题 A-Z",
+ "searchPlaceholder": "搜索",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话",
@@ -53,12 +61,31 @@
},
"nav": {
"general": "通用",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "概览",
+ "appearance": "外观",
+ "models": "模型",
+ "providers": "提供商",
+ "image": "图片",
+ "web": "网页",
+ "runtime": "运行时",
+ "advanced": "高级"
},
"sections": {
"interface": "界面",
"ai": "AI",
- "system": "系统"
+ "system": "系统",
+ "status": "状态",
+ "localPreferences": "本地偏好",
+ "presets": "预设",
+ "imageGeneration": "图片生成",
+ "imageDefaults": "默认值",
+ "webSearch": "网页搜索",
+ "webBehavior": "行为",
+ "identity": "身份",
+ "safety": "安全",
+ "capabilities": "能力",
+ "integrations": "集成"
},
"rows": {
"theme": "主题",
@@ -66,34 +93,107 @@
"provider": "提供商",
"model": "模型",
"restart": "重启 nanobot",
- "configPath": "配置路径"
+ "configPath": "配置路径",
+ "activePreset": "当前预设",
+ "gateway": "网关",
+ "restartState": "重启状态",
+ "pendingChanges": "待处理更改",
+ "selectedPreset": "选中的预设",
+ "presetModel": "预设模型",
+ "density": "密度",
+ "activityMode": "活动细节",
+ "codeWrap": "代码换行",
+ "maxResults": "最大结果数",
+ "timeout": "超时",
+ "jinaReader": "Jina Reader",
+ "imageGeneration": "图片生成",
+ "imageProvider": "图片服务商",
+ "imageProviderStatus": "服务商状态",
+ "imageProviderBase": "服务商地址",
+ "imageModel": "图片模型",
+ "defaultAspectRatio": "默认比例",
+ "defaultImageSize": "默认尺寸",
+ "maxImagesPerTurn": "每轮最多图片数",
+ "imageSaveDir": "保存目录",
+ "botName": "Bot 名称",
+ "botIcon": "Bot 图标",
+ "timezone": "时区",
+ "toolHintMaxLength": "工具提示长度",
+ "workspacePath": "工作区路径",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "统一会话",
+ "restrictWorkspace": "限制在工作区内",
+ "execTool": "Exec 工具",
+ "execSandbox": "Exec 沙箱",
+ "ssrfWhitelist": "SSRF 白名单",
+ "mcpServers": "MCP 服务器",
+ "pathAppend": "PATH 追加",
+ "configurationDocs": "配置文档"
},
"help": {
"theme": "在浅色和深色外观之间切换。",
"language": "选择 WebUI 使用的语言。",
- "provider": "选择新模型请求使用的服务提供商。",
+ "provider": "选择新模型请求使用的服务商。",
"model": "设置 nanobot 默认使用的模型名称。",
- "configPath": "当前网关正在使用的配置文件。"
+ "configPath": "当前网关正在使用的配置文件。",
+ "selectedPreset": "命名预设在这里只读;需要编辑时请改 config.json。",
+ "presetModel": "切回 Default 后可在 WebUI 编辑模型和服务商。",
+ "density": "仅保存在当前浏览器。",
+ "activityMode": "选择默认显示多少 agent 活动细节。",
+ "codeWrap": "让较小屏幕上的长代码行更易读。",
+ "maxResults": "每次 web_search 返回的结果数量。",
+ "timeout": "搜索服务商请求超时秒数。",
+ "jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
+ "imageGeneration": "当已配置图片服务商时,在对话中开放 generate_image。",
+ "imageProvider": "选择 generate_image 使用的服务商。",
+ "imageProviderStatus": "图片生成复用服务商页里的凭证配置。",
+ "imageModel": "发送给所选图片服务商的模型名称。",
+ "defaultAspectRatio": "当提示词没有选择比例时使用。",
+ "defaultImageSize": "发送给支持该能力的服务商的尺寸提示。",
+ "maxImagesPerTurn": "单次 generate_image 请求允许的图片上限。",
+ "botName": "显示在使用 bot 身份的运行时界面里。",
+ "botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
+ "timezone": "运行时上下文和计划任务使用的 IANA 时区。",
+ "toolHintMaxLength": "工具进度提示显示的最大字符数。",
+ "advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
},
"values": {
"light": "浅色",
"dark": "深色",
- "notAvailable": "不可用"
+ "notAvailable": "不可用",
+ "enabled": "已启用",
+ "disabled": "已禁用",
+ "restartPending": "等待重启",
+ "ready": "就绪",
+ "comfortable": "舒适",
+ "compact": "紧凑",
+ "auto": "自动",
+ "expanded": "展开",
+ "on": "开",
+ "off": "关",
+ "configured": "已配置",
+ "notConfigured": "未配置"
},
"status": {
"loading": "正在加载设置...",
"loadError": "无法加载设置",
"unsaved": "有未保存的更改。",
- "savedRestart": "已保存。重启 nanobot 后生效。"
+ "upToDate": "已是最新。",
+ "savedRestart": "已保存。重启 nanobot 后生效。",
+ "restartAfterSaving": "保存后,可在合适时重启。",
+ "savedRestartApply": "已保存,可稍后重启。",
+ "imageProviderRestart": "图片服务商改动已保存,可稍后重启。"
},
"actions": {
"save": "保存",
"saving": "保存中",
"edit": "编辑",
- "cancel": "取消"
+ "cancel": "取消",
+ "openDocs": "打开文档"
},
"byok": {
- "description": "自带 provider key。Nanobot 会从当前 config 读取这些值,只有已配置的 provider 才能在通用设置里选择。",
+ "description": "自带服务商密钥。Nanobot 会从当前 config 读取这些值,只有已配置的服务商才能在通用设置里选择。",
"configured": "已配置",
"notConfigured": "未配置",
"configuredSection": "已配置",
@@ -105,22 +205,22 @@
"apiKeyPlaceholder": "输入 API key",
"apiKeyConfiguredPlaceholder": "留空则保留当前 key",
"configuredKeyHint": "已配置的 key",
- "apiBasePlaceholder": "使用 provider 默认地址",
- "apiKeyRequired": "需要 API key 才能配置此 provider。",
+ "apiBasePlaceholder": "使用服务商默认地址",
+ "apiKeyRequired": "需要 API key 才能配置此服务商。",
"showApiKey": "显示 API key",
"hideApiKey": "隐藏 API key",
- "noConfiguredProviders": "没有已配置的 provider",
- "configureFirst": "请先在 BYOK 里配置 provider。",
+ "noConfiguredProviders": "没有已配置的服务商",
+ "configureFirst": "请先在 BYOK 里配置服务商。",
"openByok": "打开 BYOK",
"tabs": {
"ariaLabel": "BYOK 凭证类型",
"llm": "LLM",
- "webSearch": "Web Search"
+ "webSearch": "网页搜索"
},
"webSearch": {
- "provider": "搜索 provider",
- "providerHelp": "选择 web search 工具使用的后端。",
- "selectProvider": "选择 provider",
+ "provider": "搜索服务商",
+ "providerHelp": "选择网页搜索工具使用的后端。",
+ "selectProvider": "选择服务商",
"credentials": "凭证",
"noCredentialRequired": "无需 key",
"noCredentialHelp": "DuckDuckGo 不需要保存 API key。",
@@ -128,11 +228,31 @@
"baseUrl": "Base URL",
"baseUrlHelp": "SearXNG 需要你自己的实例地址。",
"baseUrlPlaceholder": "https://search.example.com",
- "apiKeyRequired": "这个搜索 provider 需要 API key。",
+ "apiKeyRequired": "这个搜索服务商需要 API key。",
"baseUrlRequired": "SearXNG 需要 Base URL。",
"missingCredential": "填写所需凭证后才能保存。",
- "saveHint": "改动会应用到新的 web search 请求。"
+ "saveHint": "改动会应用到新的网页搜索请求。"
}
+ },
+ "overview": {
+ "model": "当前模型",
+ "providers": "提供商",
+ "configuredCount": "已配置 {{count}} 个",
+ "totalProviders": "共 {{count}} 个可用",
+ "webSearch": "网页搜索",
+ "imageGeneration": "图片生成",
+ "workspace": "工作区"
+ },
+ "providers": {
+ "searchPlaceholder": "搜索服务商",
+ "noMatches": "没有匹配的服务商。"
+ },
+ "image": {
+ "selectProvider": "选择服务商",
+ "selectAspect": "选择比例",
+ "selectSize": "选择尺寸",
+ "configureProvider": "配置服务商",
+ "missingCredential": "启用图片生成前,请先配置这个服务商。"
}
},
"chat": {
@@ -140,12 +260,30 @@
"loading": "加载中…",
"noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作",
+ "activity": {
+ "running": "Agent 正在运行",
+ "complete": "Agent 已完成"
+ },
+ "pin": "置顶",
+ "unpin": "取消置顶",
+ "rename": "重命名",
+ "renameTitle": "重命名对话",
+ "renameDescription": "为这个对话设置一个仅用于 WebUI 侧边栏的名称。",
+ "renamePlaceholder": "对话名称",
+ "renameSave": "保存",
+ "archive": "归档",
+ "unarchive": "取消归档",
+ "showArchived": "显示归档",
+ "hideArchived": "隐藏归档",
"delete": "删除",
"newChat": "新建对话",
"groups": {
+ "pinned": "置顶",
+ "all": "对话",
"today": "今天",
"yesterday": "昨天",
- "earlier": "更早"
+ "earlier": "更早",
+ "archived": "已归档"
}
},
"deleteConfirm": {
@@ -282,7 +420,7 @@
},
"status": {
"title": "查看状态",
- "description": "显示运行时、provider 和 channel 状态。"
+ "description": "显示运行时、服务商和通道状态。"
},
"history": {
"title": "查看对话历史",
diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json
index 1e26b155..cf1cd4aa 100644
--- a/webui/src/i18n/locales/zh-TW/common.json
+++ b/webui/src/i18n/locales/zh-TW/common.json
@@ -30,13 +30,25 @@
"collapse": "收合側邊欄",
"toggleTheme": "切換主題",
"newChat": "新增對話",
+ "viewOptions": "檢視",
+ "compactList": "緊湊列表",
+ "showPreviews": "顯示預覽",
+ "showTimestamps": "顯示時間",
+ "sortLabel": "排序",
+ "sortUpdated": "最近更新",
+ "sortCreated": "最近建立",
+ "sortTitle": "標題 A-Z",
"recent": "最近對話",
"refreshSessions": "重新整理會話",
"settings": "設定",
"language": {
"label": "語言",
"ariaLabel": "切換語言"
- }
+ },
+ "searchAria": "搜尋",
+ "searchPlaceholder": "搜尋",
+ "searchResults": "搜尋結果",
+ "noSearchResults": "沒有符合的對話。"
},
"settings": {
"backToChat": "返回對話",
@@ -46,12 +58,28 @@
},
"nav": {
"general": "一般",
- "byok": "BYOK"
+ "byok": "BYOK",
+ "overview": "Overview",
+ "appearance": "Appearance",
+ "models": "Models",
+ "providers": "Providers",
+ "image": "Image",
+ "web": "Web",
+ "runtime": "Runtime",
+ "advanced": "Advanced"
},
"sections": {
"interface": "介面",
"ai": "AI",
- "system": "系統"
+ "system": "系統",
+ "status": "Status",
+ "localPreferences": "Local preferences",
+ "presets": "Presets",
+ "webSearch": "Web search",
+ "webBehavior": "Behavior",
+ "identity": "Identity",
+ "safety": "Safety",
+ "integrations": "Integrations"
},
"rows": {
"theme": "主題",
@@ -59,19 +87,70 @@
"provider": "提供者",
"model": "模型",
"restart": "重新啟動 nanobot",
- "configPath": "設定檔路徑"
+ "configPath": "設定檔路徑",
+ "activePreset": "Active preset",
+ "gateway": "Gateway",
+ "restartState": "Restart state",
+ "selectedPreset": "Selected preset",
+ "presetModel": "Preset model",
+ "density": "Density",
+ "activityMode": "Activity detail",
+ "codeWrap": "Code wrapping",
+ "maxResults": "Max results",
+ "timeout": "Timeout",
+ "jinaReader": "Jina reader",
+ "botName": "Bot name",
+ "botIcon": "Bot icon",
+ "timezone": "Timezone",
+ "toolHintMaxLength": "Tool hint length",
+ "workspacePath": "Workspace path",
+ "heartbeat": "Heartbeat",
+ "dream": "Dream",
+ "unifiedSession": "Unified session",
+ "restrictWorkspace": "Restrict to workspace",
+ "execTool": "Exec tool",
+ "execSandbox": "Exec sandbox",
+ "ssrfWhitelist": "SSRF whitelist",
+ "mcpServers": "MCP servers",
+ "pathAppend": "PATH append",
+ "configurationDocs": "Configuration docs"
},
"help": {
"theme": "在淺色與深色外觀之間切換。",
"language": "選擇 WebUI 使用的語言。",
"provider": "選擇新模型請求使用的服務提供者。",
"model": "設定 nanobot 預設使用的模型名稱。",
- "configPath": "目前閘道正在使用的設定檔。"
+ "configPath": "目前閘道正在使用的設定檔。",
+ "selectedPreset": "Named presets are read-only here; edit them in config.json.",
+ "presetModel": "Switch to Default to edit model and provider from the WebUI.",
+ "density": "Stored only in this browser.",
+ "activityMode": "Choose how much agent activity chrome to show by default.",
+ "codeWrap": "Keep long code lines readable on smaller screens.",
+ "maxResults": "Results returned by each web_search call.",
+ "timeout": "Seconds before a search provider request times out.",
+ "jinaReader": "Use Jina Reader for web_fetch when available.",
+ "botName": "Shown in runtime surfaces that use the configured bot identity.",
+ "botIcon": "Short emoji or text shown beside the bot name.",
+ "timezone": "IANA timezone used by runtime context and schedules.",
+ "toolHintMaxLength": "Maximum characters shown in tool progress hints.",
+ "advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"values": {
"light": "淺色",
"dark": "深色",
- "notAvailable": "不可用"
+ "notAvailable": "不可用",
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "restartRequired": "Restart required",
+ "liveReload": "Live reload ready",
+ "comfortable": "Comfortable",
+ "compact": "Compact",
+ "auto": "Auto",
+ "expanded": "Expanded",
+ "on": "On",
+ "off": "Off",
+ "configured": "Configured",
+ "notConfigured": "Not configured"
},
"status": {
"loading": "正在載入設定...",
@@ -83,7 +162,8 @@
"save": "儲存",
"saving": "儲存中",
"edit": "編輯",
- "cancel": "取消"
+ "cancel": "取消",
+ "openDocs": "Open docs"
},
"byok": {
"description": "自帶 provider key。Nanobot 會從目前 config 讀取這些值,只有已設定的 provider 才能在一般設定中選擇。",
@@ -126,6 +206,18 @@
"missingCredential": "填寫必要憑證後才能儲存。",
"saveHint": "變更會套用到新的 web search 請求。"
}
+ },
+ "overview": {
+ "model": "Current model",
+ "providers": "Providers",
+ "configuredCount": "{{count}} configured",
+ "totalProviders": "{{count}} available",
+ "webSearch": "Web search",
+ "workspace": "Workspace"
+ },
+ "providers": {
+ "searchPlaceholder": "Search providers",
+ "noMatches": "No providers match this search."
}
},
"chat": {
@@ -133,8 +225,31 @@
"loading": "載入中…",
"noSessions": "目前還沒有會話。",
"actions": "「{{title}}」的會話操作",
+ "activity": {
+ "running": "Agent 正在執行",
+ "complete": "Agent 已完成"
+ },
+ "pin": "置頂",
+ "unpin": "取消置頂",
+ "rename": "重新命名",
+ "renameTitle": "重新命名對話",
+ "renameDescription": "為這個對話設定僅用於 WebUI 側邊欄的名稱。",
+ "renamePlaceholder": "對話名稱",
+ "renameSave": "儲存",
+ "archive": "封存",
+ "unarchive": "取消封存",
+ "showArchived": "顯示封存",
+ "hideArchived": "隱藏封存",
"delete": "刪除",
- "newChat": "新增對話"
+ "newChat": "新增對話",
+ "groups": {
+ "pinned": "置頂",
+ "all": "對話",
+ "today": "今天",
+ "yesterday": "昨天",
+ "earlier": "更早",
+ "archived": "已封存"
+ }
},
"deleteConfirm": {
"title": "刪除這個對話?",
diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts
index 058ca29c..d3133675 100644
--- a/webui/src/lib/api.ts
+++ b/webui/src/lib/api.ts
@@ -1,8 +1,10 @@
import type {
ChatSummary,
+ ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
SettingsUpdate,
+ SidebarStatePayload,
SlashCommand,
WebSearchSettingsUpdate,
WebuiThreadPersistedPayload,
@@ -52,6 +54,7 @@ export async function listSessions(
updated_at: string | null;
title?: string;
preview?: string;
+ run_started_at?: number | null;
};
const body = await request<{ sessions: Row[] }>(
`${base}/api/sessions`,
@@ -64,6 +67,7 @@ export async function listSessions(
updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "",
+ runStartedAt: s.run_started_at ?? null,
}));
}
@@ -125,14 +129,43 @@ export async function listSlashCommands(
}));
}
+export async function fetchSidebarState(
+ token: string,
+ base: string = "",
+): Promise {
+ return request(`${base}/api/webui/sidebar-state`, token);
+}
+
+export async function updateSidebarState(
+ token: string,
+ state: SidebarStatePayload,
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ query.set("state", JSON.stringify(state));
+ return request(
+ `${base}/api/webui/sidebar-state/update?${query}`,
+ token,
+ );
+}
+
export async function updateSettings(
token: string,
update: SettingsUpdate,
base: string = "",
): Promise {
const query = new URLSearchParams();
+ if (update.modelPreset !== undefined) {
+ query.set("model_preset", update.modelPreset ?? "default");
+ }
if (update.model !== undefined) query.set("model", update.model);
if (update.provider !== undefined) query.set("provider", update.provider);
+ if (update.timezone !== undefined) query.set("timezone", update.timezone);
+ if (update.botName !== undefined) query.set("bot_name", update.botName);
+ if (update.botIcon !== undefined) query.set("bot_icon", update.botIcon);
+ if (update.toolHintMaxLength !== undefined) {
+ query.set("tool_hint_max_length", String(update.toolHintMaxLength));
+ }
return request(`${base}/api/settings/update?${query}`, token);
}
@@ -160,8 +193,31 @@ export async function updateWebSearchSettings(
query.set("provider", update.provider);
if (update.apiKey !== undefined) query.set("api_key", update.apiKey);
if (update.baseUrl !== undefined) query.set("base_url", update.baseUrl);
+ if (update.maxResults !== undefined) query.set("max_results", String(update.maxResults));
+ if (update.timeout !== undefined) query.set("timeout", String(update.timeout));
+ if (update.useJinaReader !== undefined) {
+ query.set("use_jina_reader", String(update.useJinaReader));
+ }
return request(
`${base}/api/settings/web-search/update?${query}`,
token,
);
}
+
+export async function updateImageGenerationSettings(
+ token: string,
+ update: ImageGenerationSettingsUpdate,
+ base: string = "",
+): Promise {
+ const query = new URLSearchParams();
+ query.set("enabled", String(update.enabled));
+ query.set("provider", update.provider);
+ query.set("model", update.model);
+ query.set("default_aspect_ratio", update.defaultAspectRatio);
+ query.set("default_image_size", update.defaultImageSize);
+ query.set("max_images_per_turn", String(update.maxImagesPerTurn));
+ return request(
+ `${base}/api/settings/image-generation/update?${query}`,
+ token,
+ );
+}
diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts
index dfddf688..0587520a 100644
--- a/webui/src/lib/nanobot-client.ts
+++ b/webui/src/lib/nanobot-client.ts
@@ -56,6 +56,7 @@ type StatusHandler = (status: ConnectionStatus) => void;
type RuntimeModelHandler = (modelName: string | null, modelPreset?: string | null) => void;
type SessionUpdateScope = "metadata" | "thread" | string;
type SessionUpdateHandler = (chatId: string, scope?: SessionUpdateScope) => void;
+type RunStatusHandler = (chatId: string, startedAt: number | null) => void;
/** Structured connection-level errors surfaced to the UI.
*
@@ -102,6 +103,7 @@ export class NanobotClient {
private statusHandlers = new Set();
private runtimeModelHandlers = new Set();
private sessionUpdateHandlers = new Set();
+ private runStatusHandlers = new Set();
private errorHandlers = new Set();
// chat_id -> handlers listening on it
private chatHandlers = new Map>();
@@ -172,6 +174,16 @@ export class NanobotClient {
};
}
+ onRunStatus(handler: RunStatusHandler): Unsubscribe {
+ this.runStatusHandlers.add(handler);
+ for (const [chatId, startedAt] of this.runStartedAtByChatId) {
+ handler(chatId, startedAt);
+ }
+ return () => {
+ this.runStatusHandlers.delete(handler);
+ };
+ }
+
/** Subscribe to transport-level faults (see :type:`StreamError`). */
onError(handler: ErrorHandler): Unsubscribe {
this.errorHandlers.add(handler);
@@ -194,9 +206,12 @@ export class NanobotClient {
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
+ const previous = this.runStartedAtByChatId.get(chatId);
this.runStartedAtByChatId.set(chatId, ev.started_at);
- } else {
+ if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
+ } else if (this.runStartedAtByChatId.has(chatId)) {
this.runStartedAtByChatId.delete(chatId);
+ this.emitRunStatus(chatId, null);
}
}
@@ -389,6 +404,12 @@ export class NanobotClient {
}
}
+ private emitRunStatus(chatId: string, startedAt: number | null): void {
+ for (const handler of this.runStatusHandlers) {
+ handler(chatId, startedAt);
+ }
+ }
+
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (handlers !== undefined && handlers.size > 0) {
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index c8c7e96e..66342e9a 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -110,6 +110,30 @@ export interface ChatSummary {
updatedAt: string | null;
title?: string;
preview: string;
+ /** Unix epoch seconds when this session currently has a turn in flight. */
+ runStartedAt?: number | null;
+}
+
+export type SidebarDensity = "comfortable" | "compact";
+export type SidebarSortMode = "updated_desc" | "created_desc" | "title_asc";
+
+export interface SidebarViewState {
+ density: SidebarDensity;
+ show_previews: boolean;
+ show_timestamps: boolean;
+ show_archived: boolean;
+ sort: SidebarSortMode;
+}
+
+export interface SidebarStatePayload {
+ schema_version: number;
+ pinned_keys: string[];
+ archived_keys: string[];
+ title_overrides: Record;
+ tags_by_key: Record;
+ collapsed_groups: Record;
+ view: SidebarViewState;
+ updated_at?: string | null;
}
export interface BootstrapResponse {
@@ -125,7 +149,28 @@ export interface SettingsPayload {
provider: string;
resolved_provider: string | null;
has_api_key: boolean;
+ model_preset: string | null;
+ max_tokens: number;
+ context_window_tokens: number;
+ temperature: number;
+ reasoning_effort: string | null;
+ timezone: string;
+ bot_name: string;
+ bot_icon: string;
+ tool_hint_max_length: number;
};
+ model_presets: Array<{
+ name: string;
+ label: string;
+ active: boolean;
+ is_default: boolean;
+ model: string;
+ provider: string;
+ max_tokens: number;
+ context_window_tokens: number;
+ temperature: number;
+ reasoning_effort: string | null;
+ }>;
providers: Array<{
name: string;
label: string;
@@ -139,21 +184,82 @@ export interface SettingsPayload {
provider: string;
api_key_hint?: string | null;
base_url?: string | null;
+ max_results: number;
+ timeout: number;
providers: Array<{
name: string;
label: string;
credential: "none" | "api_key" | "base_url";
}>;
};
+ web: {
+ enable: boolean;
+ proxy?: string | null;
+ user_agent?: string | null;
+ search: {
+ max_results: number;
+ timeout: number;
+ };
+ fetch: {
+ use_jina_reader: boolean;
+ };
+ };
+ image_generation: {
+ enabled: boolean;
+ provider: string;
+ provider_configured: boolean;
+ model: string;
+ default_aspect_ratio: string;
+ default_image_size: string;
+ max_images_per_turn: number;
+ save_dir: string;
+ providers: Array<{
+ name: string;
+ label: string;
+ configured: boolean;
+ api_key_hint?: string | null;
+ api_base?: string | null;
+ default_api_base?: string | null;
+ }>;
+ };
runtime: {
config_path: string;
+ workspace_path: string;
+ gateway_host: string;
+ gateway_port: number;
+ heartbeat: {
+ enabled: boolean;
+ interval_s: number;
+ keep_recent_messages: number;
+ };
+ dream: {
+ schedule: string;
+ max_batch_size: number;
+ max_iterations: number;
+ annotate_line_ages: boolean;
+ };
+ unified_session: boolean;
+ };
+ advanced: {
+ restrict_to_workspace: boolean;
+ ssrf_whitelist_count: number;
+ mcp_server_count: number;
+ exec_enabled: boolean;
+ exec_sandbox?: string | null;
+ exec_path_append_set: boolean;
};
requires_restart: boolean;
+ restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface SettingsUpdate {
model?: string;
provider?: string;
+ modelPreset?: string | null;
+ timezone?: string;
+ botName?: string;
+ botIcon?: string;
+ toolHintMaxLength?: number;
}
export interface ProviderSettingsUpdate {
@@ -166,6 +272,18 @@ export interface WebSearchSettingsUpdate {
provider: string;
apiKey?: string;
baseUrl?: string;
+ maxResults?: number;
+ timeout?: number;
+ useJinaReader?: boolean;
+}
+
+export interface ImageGenerationSettingsUpdate {
+ enabled: boolean;
+ provider: string;
+ model: string;
+ defaultAspectRatio: string;
+ defaultImageSize: string;
+ maxImagesPerTurn: number;
}
export interface SlashCommand {
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index baee87f6..831f36ae 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSession,
+ fetchSidebarState,
fetchWebuiThread,
listSessions,
listSlashCommands,
+ updateSidebarState,
+ updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
@@ -46,12 +49,17 @@ describe("webui API helpers", () => {
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
+ modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
+ timezone: "Asia/Shanghai",
+ botName: "nanobot",
+ botIcon: "nb",
+ toolHintMaxLength: 120,
});
expect(fetch).toHaveBeenCalledWith(
- "/api/settings/update?model=openrouter%2Ftest&provider=openrouter",
+ "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -77,16 +85,81 @@ describe("webui API helpers", () => {
await updateWebSearchSettings("tok", {
provider: "searxng",
baseUrl: "https://search.example.com",
+ maxResults: 8,
+ timeout: 45,
+ useJinaReader: false,
});
expect(fetch).toHaveBeenCalledWith(
- "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com",
+ "/api/settings/web-search/update?provider=searxng&base_url=https%3A%2F%2Fsearch.example.com&max_results=8&timeout=45&use_jina_reader=false",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
+ it("serializes image generation settings updates", async () => {
+ await updateImageGenerationSettings("tok", {
+ enabled: true,
+ provider: "openrouter",
+ model: "openai/gpt-5.4-image-2",
+ defaultAspectRatio: "16:9",
+ defaultImageSize: "2K",
+ maxImagesPerTurn: 3,
+ });
+
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/settings/image-generation/update?enabled=true&provider=openrouter&model=openai%2Fgpt-5.4-image-2&default_aspect_ratio=16%3A9&default_image_size=2K&max_images_per_turn=3",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+ });
+
+ it("reads and writes persisted sidebar state", async () => {
+ const state = {
+ schema_version: 1,
+ pinned_keys: ["websocket:chat-1"],
+ archived_keys: ["websocket:old"],
+ title_overrides: { "websocket:chat-1": "Release" },
+ tags_by_key: {},
+ collapsed_groups: {},
+ view: {
+ density: "compact" as const,
+ show_previews: false,
+ show_timestamps: false,
+ show_archived: true,
+ sort: "updated_desc" as const,
+ },
+ updated_at: null,
+ };
+ vi.mocked(fetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => state,
+ } as Response);
+
+ await expect(fetchSidebarState("tok")).resolves.toEqual(state);
+ expect(fetch).toHaveBeenCalledWith(
+ "/api/webui/sidebar-state",
+ expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }),
+ );
+
+ await updateSidebarState("tok", state);
+ const [url, init] = vi.mocked(fetch).mock.calls.at(-1)!;
+ expect(String(url).startsWith("/api/webui/sidebar-state/update?")).toBe(true);
+ expect(init).toEqual(expect.objectContaining({
+ headers: { Authorization: "Bearer tok" },
+ }));
+ const encodedState = new URLSearchParams(String(url).split("?", 2)[1]).get("state");
+ expect(encodedState).toBeTruthy();
+ expect(JSON.parse(encodedState ?? "{}")).toMatchObject({
+ pinned_keys: ["websocket:chat-1"],
+ title_overrides: { "websocket:chat-1": "Release" },
+ });
+ });
+
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
@@ -97,6 +170,7 @@ describe("webui API helpers", () => {
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
+ run_started_at: 1_700_000_000,
},
],
}),
@@ -107,6 +181,7 @@ describe("webui API helpers", () => {
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
+ runStartedAt: 1_700_000_000,
},
]);
});
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 2f48ba40..26a5b429 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -9,6 +9,8 @@ const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
+const attachSpy = vi.fn();
+const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@@ -67,9 +69,16 @@ vi.mock("@/lib/nanobot-client", () => {
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
+ onSessionUpdate = () => () => {};
+ onRunStatus = (handler: (chatId: string, startedAt: number | null) => void) => {
+ runStatusHandlers.add(handler);
+ return () => runStatusHandlers.delete(handler);
+ };
+ getRunStartedAt = () => null;
+ getGoalState = () => undefined;
sendMessage = vi.fn();
newChat = vi.fn();
- attach = vi.fn();
+ attach = attachSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
}
@@ -89,6 +98,9 @@ describe("App layout", () => {
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
+ attachSpy.mockReset();
+ runStatusHandlers.clear();
+ localStorage.removeItem("nanobot-webui.sidebar.completed-runs.v1");
vi.mocked(fetchBootstrap).mockReset().mockResolvedValue({
token: "tok",
ws_path: "/",
@@ -175,6 +187,318 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
+ it("keeps the mobile session action menu inside the sidebar sheet", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Existing chat",
+ },
+ ];
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockImplementation((query: string) => ({
+ matches: !query.includes("1024px"),
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ );
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
+
+ const sheet = await screen.findByRole("dialog");
+ const mobileSidebar = within(sheet).getByRole("navigation", {
+ name: "Sidebar navigation",
+ });
+ await waitFor(() =>
+ expect(
+ within(mobileSidebar).getByRole("button", { name: /^Existing chat$/ }),
+ ).toBeInTheDocument(),
+ );
+
+ fireEvent.pointerDown(
+ within(mobileSidebar).getByLabelText("Chat actions for Existing chat"),
+ { button: 0 },
+ );
+
+ const deleteItem = await within(sheet).findByRole("menuitem", {
+ name: "Delete",
+ });
+ expect(deleteItem).toBeInTheDocument();
+
+ fireEvent.click(deleteItem);
+ await waitFor(() =>
+ expect(screen.getByText("Delete this chat?")).toBeInTheDocument(),
+ );
+ }, 15_000);
+
+ it("applies persisted sidebar workspace state from the gateway", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "First chat",
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Second chat",
+ },
+ ];
+ const initialState = {
+ schema_version: 1,
+ pinned_keys: ["websocket:chat-b"],
+ archived_keys: ["websocket:chat-a"],
+ title_overrides: { "websocket:chat-b": "Roadmap" },
+ tags_by_key: {},
+ collapsed_groups: {},
+ view: {
+ density: "comfortable",
+ show_previews: false,
+ show_timestamps: false,
+ show_archived: false,
+ sort: "updated_desc",
+ },
+ updated_at: null,
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation(async (url: string | URL | Request) => {
+ const href = String(url);
+ if (href === "/api/webui/sidebar-state") {
+ return { ok: true, json: async () => initialState };
+ }
+ if (href.startsWith("/api/webui/sidebar-state/update?")) {
+ const encoded = new URLSearchParams(href.split("?", 2)[1]).get("state");
+ return {
+ ok: true,
+ json: async () => JSON.parse(encoded ?? "{}"),
+ };
+ }
+ return { ok: false, status: 404 };
+ }),
+ );
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
+ await waitFor(() =>
+ expect(within(sidebar).getByText("Pinned")).toBeInTheDocument(),
+ );
+ expect(within(sidebar).getByRole("button", { name: /^Roadmap$/ })).toBeInTheDocument();
+ expect(within(sidebar).queryByRole("button", { name: /^First chat$/ })).not.toBeInTheDocument();
+
+ fireEvent.click(within(sidebar).getByRole("button", { name: "Show archived" }));
+ await waitFor(() =>
+ expect(within(sidebar).getByText("Archived")).toBeInTheDocument(),
+ );
+ expect(within(sidebar).getByRole("button", { name: /^First chat$/ })).toBeInTheDocument();
+ const updateUrl = vi.mocked(fetch).mock.calls
+ .map(([url]) => String(url))
+ .find((url) => url.startsWith("/api/webui/sidebar-state/update?"));
+ expect(updateUrl).toBeTruthy();
+ const encoded = new URLSearchParams(updateUrl?.split("?", 2)[1]).get("state");
+ expect(JSON.parse(encoded ?? "{}").view.show_archived).toBe(true);
+
+ fireEvent.pointerDown(within(sidebar).getByRole("button", { name: "View" }), {
+ button: 0,
+ ctrlKey: false,
+ });
+ fireEvent.click(await screen.findByText("Compact list"));
+ await waitFor(() => {
+ const lastUpdateUrl = vi.mocked(fetch).mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
+ .at(-1);
+ const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
+ expect(JSON.parse(lastEncoded ?? "{}").view.density).toBe("compact");
+ });
+
+ fireEvent.click(screen.getByText("Title A-Z"));
+ await waitFor(() => {
+ const lastUpdateUrl = vi.mocked(fetch).mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.startsWith("/api/webui/sidebar-state/update?"))
+ .at(-1);
+ const lastEncoded = new URLSearchParams(lastUpdateUrl?.split("?", 2)[1]).get("state");
+ expect(JSON.parse(lastEncoded ?? "{}").view.sort).toBe("title_asc");
+ });
+ });
+
+ it("sorts chats by displayed title when A-Z is persisted", async () => {
+ mockSessions = [
+ {
+ key: "websocket:zulu",
+ channel: "websocket",
+ chatId: "zulu",
+ createdAt: "2026-04-16T12:00:00Z",
+ updatedAt: "2026-04-16T12:00:00Z",
+ title: "Zulu work",
+ preview: "later",
+ },
+ {
+ key: "websocket:new",
+ channel: "websocket",
+ chatId: "new",
+ createdAt: "2026-04-15T12:00:00Z",
+ updatedAt: "2026-04-15T12:00:00Z",
+ preview: "hi nanobot",
+ },
+ {
+ key: "websocket:alpha",
+ channel: "websocket",
+ chatId: "alpha",
+ createdAt: "2026-04-14T12:00:00Z",
+ updatedAt: "2026-04-14T12:00:00Z",
+ title: "Alpha plan",
+ preview: "earlier",
+ },
+ ];
+ const initialState = {
+ schema_version: 1,
+ pinned_keys: [],
+ archived_keys: [],
+ title_overrides: {},
+ tags_by_key: {},
+ collapsed_groups: {},
+ view: {
+ density: "comfortable",
+ show_previews: false,
+ show_timestamps: false,
+ show_archived: false,
+ sort: "title_asc",
+ },
+ updated_at: null,
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation(async (url: string | URL | Request) => {
+ const href = String(url);
+ if (href === "/api/webui/sidebar-state") {
+ return { ok: true, json: async () => initialState };
+ }
+ return { ok: false, status: 404 };
+ }),
+ );
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
+ await waitFor(() =>
+ expect(within(sidebar).getByText("Chats")).toBeInTheDocument(),
+ );
+ const group = within(sidebar).getByText("Chats").closest("section");
+ expect(group).toBeTruthy();
+ const labels = within(group as HTMLElement)
+ .getAllByRole("button")
+ .map((button) => button.textContent?.trim())
+ .filter(Boolean);
+
+ expect(labels).toEqual(["Alpha plan", "New chat", "Zulu work"]);
+ });
+
+ it("shows running and completed session indicators in the sidebar", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Working chat",
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Quiet chat",
+ },
+ ];
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
+ await waitFor(() =>
+ expect(
+ within(sidebar).getByRole("button", { name: /^Working chat$/ }),
+ ).toBeInTheDocument(),
+ );
+
+ act(() => {
+ for (const handler of runStatusHandlers) handler("chat-a", 12_345);
+ });
+ expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument();
+
+ act(() => {
+ for (const handler of runStatusHandlers) handler("chat-a", null);
+ });
+ expect(within(sidebar).queryByTitle("Agent running")).not.toBeInTheDocument();
+ expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(within(sidebar).getByRole("button", { name: /^Working chat$/ }));
+ });
+ expect(within(sidebar).queryByTitle("Agent finished")).not.toBeInTheDocument();
+ });
+
+ it("restores sidebar run indicators after a page reload", async () => {
+ mockSessions = [
+ {
+ key: "websocket:chat-a",
+ channel: "websocket",
+ chatId: "chat-a",
+ createdAt: "2026-04-16T10:00:00Z",
+ updatedAt: "2026-04-16T10:00:00Z",
+ preview: "Running after reload",
+ runStartedAt: 12_345,
+ },
+ {
+ key: "websocket:chat-b",
+ channel: "websocket",
+ chatId: "chat-b",
+ createdAt: "2026-04-16T11:00:00Z",
+ updatedAt: "2026-04-16T11:00:00Z",
+ preview: "Completed after reload",
+ },
+ ];
+ localStorage.setItem(
+ "nanobot-webui.sidebar.completed-runs.v1",
+ JSON.stringify(["chat-b"]),
+ );
+
+ render();
+
+ await waitFor(() => expect(connectSpy).toHaveBeenCalled());
+ const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
+ await waitFor(() =>
+ expect(within(sidebar).getByTitle("Agent running")).toBeInTheDocument(),
+ );
+ expect(within(sidebar).getByTitle("Agent finished")).toBeInTheDocument();
+ expect(attachSpy).toHaveBeenCalledWith("chat-a");
+ });
+
it("opens the settings view from the sidebar footer", async () => {
mockSessions = [
{
@@ -199,7 +523,42 @@ describe("App layout", () => {
provider: "auto",
resolved_provider: "openai",
has_api_key: true,
+ model_preset: "default",
+ max_tokens: 8192,
+ context_window_tokens: 65536,
+ temperature: 0.1,
+ reasoning_effort: null,
+ timezone: "UTC",
+ bot_name: "nanobot",
+ bot_icon: "nb",
+ tool_hint_max_length: 40,
},
+ model_presets: [
+ {
+ name: "default",
+ label: "Default",
+ active: true,
+ is_default: true,
+ model: "openai/gpt-4o",
+ provider: "auto",
+ max_tokens: 8192,
+ context_window_tokens: 65536,
+ temperature: 0.1,
+ reasoning_effort: null,
+ },
+ {
+ name: "deep",
+ label: "deep",
+ active: false,
+ is_default: false,
+ model: "anthropic/claude-opus-4-5",
+ provider: "anthropic",
+ max_tokens: 8192,
+ context_window_tokens: 200000,
+ temperature: 0.1,
+ reasoning_effort: "high",
+ },
+ ],
providers: [
{
name: "openai",
@@ -269,14 +628,74 @@ describe("App layout", () => {
provider: "brave",
api_key_hint: "BSAo••••ew20",
base_url: null,
+ max_results: 5,
+ timeout: 30,
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" },
{ name: "tavily", label: "Tavily", credential: "api_key" },
],
},
+ web: {
+ enable: true,
+ proxy: null,
+ user_agent: null,
+ search: { max_results: 5, timeout: 30 },
+ fetch: { use_jina_reader: true },
+ },
+ image_generation: {
+ enabled: false,
+ provider: "openrouter",
+ provider_configured: true,
+ model: "openai/gpt-5.4-image-2",
+ default_aspect_ratio: "1:1",
+ default_image_size: "1K",
+ max_images_per_turn: 4,
+ save_dir: "generated",
+ providers: [
+ {
+ name: "openrouter",
+ label: "OpenRouter",
+ configured: true,
+ api_key_hint: "sk-o••••test",
+ api_base: "https://openrouter.ai/api/v1",
+ default_api_base: "https://openrouter.ai/api/v1",
+ },
+ {
+ name: "gemini",
+ label: "Gemini",
+ configured: false,
+ api_key_hint: null,
+ api_base: null,
+ default_api_base: "https://generativelanguage.googleapis.com/v1beta/openai/",
+ },
+ ],
+ },
runtime: {
config_path: "/tmp/config.json",
+ workspace_path: "/tmp/workspace",
+ gateway_host: "127.0.0.1",
+ gateway_port: 18790,
+ heartbeat: {
+ enabled: true,
+ interval_s: 1800,
+ keep_recent_messages: 8,
+ },
+ dream: {
+ schedule: "every 2h",
+ max_batch_size: 20,
+ max_iterations: 15,
+ annotate_line_ages: true,
+ },
+ unified_session: false,
+ },
+ advanced: {
+ restrict_to_workspace: false,
+ ssrf_whitelist_count: 0,
+ mcp_server_count: 0,
+ exec_enabled: true,
+ exec_sandbox: null,
+ exec_path_append_set: false,
},
requires_restart: false,
}),
@@ -292,21 +711,32 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
- expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
expect(document.title).toBe("Settings · nanobot");
expect(screen.queryByRole("navigation", { name: "Sidebar navigation" })).not.toBeInTheDocument();
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
- expect(within(settingsNav).getByRole("button", { name: "General" })).toHaveAttribute(
+ expect(settingsNav.className).toContain("overflow-x-auto");
+ expect(settingsNav.className).not.toContain("grid-cols-2");
+ expect(within(settingsNav).getByRole("button", { name: "Overview" })).toHaveAttribute(
"aria-current",
"page",
);
- expect(within(settingsNav).getByRole("button", { name: "BYOK" })).toBeInTheDocument();
+ expect(within(settingsNav).getByRole("button", { name: "Models" })).toBeInTheDocument();
+ expect(within(settingsNav).getByRole("button", { name: "Providers" })).toBeInTheDocument();
+ expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
+ expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
+ expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
+ fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument();
- expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
- fireEvent.click(within(settingsNav).getByRole("button", { name: "BYOK" }));
- expect(screen.getByRole("tab", { name: "LLM" })).toHaveAttribute("aria-selected", "true");
- expect(screen.getByRole("tab", { name: "Web Search" })).toBeInTheDocument();
+ const modelInput = screen.getByDisplayValue("openai/gpt-4o");
+ expect(modelInput).toBeInTheDocument();
+ fireEvent.change(modelInput, { target: { value: "openai/gpt-4o-mini" } });
+ expect(screen.getByText("Unsaved changes.").parentElement?.className).toContain(
+ "text-blue-600",
+ );
+ fireEvent.change(modelInput, { target: { value: "openai/gpt-4o" } });
+ fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
@@ -325,7 +755,14 @@ describe("App layout", () => {
expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
- fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
+ fireEvent.click(within(settingsNav).getByRole("button", { name: "Image" }));
+ expect(screen.getByRole("heading", { name: "Image" })).toBeInTheDocument();
+ expect(screen.getByText("Provider status")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("openai/gpt-5.4-image-2")).toBeInTheDocument();
+ expect(screen.getByText("Save directory")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+
+ fireEvent.click(within(settingsNav).getByRole("button", { name: "Web" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Brave Search/ })).toBeInTheDocument();
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
@@ -339,6 +776,10 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("menuitem", { name: "Brave Search" }));
expect(screen.getByText("BSAo••••ew20")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-brave-key")).not.toBeInTheDocument();
+
+ fireEvent.click(within(settingsNav).getByRole("button", { name: "Runtime" }));
+ expect(screen.getByText("Bot name")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
it("returns from settings to the blank start page when no session was active", async () => {
@@ -373,19 +814,94 @@ describe("App layout", () => {
provider: "openai",
resolved_provider: "openai",
has_api_key: true,
+ model_preset: "default",
+ max_tokens: 8192,
+ context_window_tokens: 65536,
+ temperature: 0.1,
+ reasoning_effort: null,
+ timezone: "UTC",
+ bot_name: "nanobot",
+ bot_icon: "nb",
+ tool_hint_max_length: 40,
},
+ model_presets: [
+ {
+ name: "default",
+ label: "Default",
+ active: true,
+ is_default: true,
+ model: "openai/gpt-4o",
+ provider: "openai",
+ max_tokens: 8192,
+ context_window_tokens: 65536,
+ temperature: 0.1,
+ reasoning_effort: null,
+ },
+ ],
providers: [{ name: "openai", label: "OpenAI", configured: true }],
web_search: {
provider: "duckduckgo",
api_key_hint: null,
base_url: null,
+ max_results: 5,
+ timeout: 30,
providers: [
{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" },
{ name: "brave", label: "Brave Search", credential: "api_key" },
],
},
+ web: {
+ enable: true,
+ proxy: null,
+ user_agent: null,
+ search: { max_results: 5, timeout: 30 },
+ fetch: { use_jina_reader: true },
+ },
+ image_generation: {
+ enabled: false,
+ provider: "openrouter",
+ provider_configured: false,
+ model: "openai/gpt-5.4-image-2",
+ default_aspect_ratio: "1:1",
+ default_image_size: "1K",
+ max_images_per_turn: 4,
+ save_dir: "generated",
+ providers: [
+ {
+ name: "openrouter",
+ label: "OpenRouter",
+ configured: false,
+ api_key_hint: null,
+ api_base: null,
+ default_api_base: "https://openrouter.ai/api/v1",
+ },
+ ],
+ },
runtime: {
config_path: "/tmp/config.json",
+ workspace_path: "/tmp/workspace",
+ gateway_host: "127.0.0.1",
+ gateway_port: 18790,
+ heartbeat: {
+ enabled: true,
+ interval_s: 1800,
+ keep_recent_messages: 8,
+ },
+ dream: {
+ schedule: "every 2h",
+ max_batch_size: 20,
+ max_iterations: 15,
+ annotate_line_ages: true,
+ },
+ unified_session: false,
+ },
+ advanced: {
+ restrict_to_workspace: false,
+ ssrf_whitelist_count: 0,
+ mcp_server_count: 0,
+ exec_enabled: true,
+ exec_sandbox: null,
+ exec_path_append_set: false,
},
requires_restart: false,
}),
@@ -403,14 +919,14 @@ describe("App layout", () => {
await waitFor(() => expect(document.title).toBe("nanobot"));
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
- expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
+ expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
await waitFor(() => expect(document.title).toBe("nanobot"));
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
});
- it("filters sidebar sessions through the lightweight search row", async () => {
+ it("filters sessions in the centered search dialog", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
@@ -437,20 +953,43 @@ describe("App layout", () => {
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
+ const newChatButton = within(sidebar).getByRole("button", { name: "New chat" });
+ const searchButton = within(sidebar).getByRole("button", { name: "Search" });
+ expect(
+ newChatButton.compareDocumentPosition(searchButton) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
- fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
+ fireEvent.click(searchButton);
+ const dialog = await screen.findByRole("dialog", { name: "Search" });
+ expect(dialog).toHaveClass("origin-center");
+ expect(dialog.className).not.toContain("translate-x");
+ expect(dialog.className).not.toContain("translate-y");
+ expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
+ expect(within(dialog).getByText("Travel ideas")).toBeInTheDocument();
+ expect(within(dialog).queryByText("websocket")).not.toBeInTheDocument();
+ expect(within(dialog).queryByText("#1")).not.toBeInTheDocument();
+
+ fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
target: { value: "planning" },
});
- expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
- expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
+ expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
+ expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
+ expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
- fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
+ fireEvent.change(within(dialog).getByRole("textbox", { name: "Search" }), {
target: { value: "road q2" },
});
- expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
- expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
+ expect(within(dialog).getByText("Q2 roadmap")).toBeInTheDocument();
+ expect(within(dialog).queryByText("Travel ideas")).not.toBeInTheDocument();
+
+ fireEvent.click(within(dialog).getByRole("button", { name: /Q2 roadmap/ }));
+
+ await waitFor(() =>
+ expect(screen.queryByRole("dialog", { name: "Search" })).not.toBeInTheDocument(),
+ );
});
it("opens a blank start page without creating an empty chat", async () => {
diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx
index 031b1aee..d1359121 100644
--- a/webui/src/tests/i18n.test.tsx
+++ b/webui/src/tests/i18n.test.tsx
@@ -8,7 +8,16 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
-const SETTINGS_NAV_KEYS = ["general", "byok"];
+const SETTINGS_NAV_KEYS = [
+ "overview",
+ "appearance",
+ "models",
+ "providers",
+ "image",
+ "web",
+ "runtime",
+ "advanced",
+];
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
@@ -87,4 +96,14 @@ describe("webui i18n", () => {
expect(common.settings.byok.configuredKeyHint).toBeTruthy();
}
});
+
+ it("keeps Simplified Chinese settings overview copy localized", () => {
+ const settings = resources["zh-CN"].common.settings;
+
+ expect(settings.nav.web).toBe("网页");
+ expect(settings.sections.webSearch).toBe("网页搜索");
+ expect(settings.byok.tabs.webSearch).toBe("网页搜索");
+ expect(settings.overview.webSearch).toBe("网页搜索");
+ expect(settings.overview.workspace).toBe("工作区");
+ });
});
diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts
index 6b434bb3..9db74d46 100644
--- a/webui/src/tests/nanobot-client.test.ts
+++ b/webui/src/tests/nanobot-client.test.ts
@@ -132,6 +132,37 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
+ it("notifies run status subscribers and replays running chats", () => {
+ const client = new NanobotClient({
+ url: "ws://test",
+ reconnect: false,
+ socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
+ });
+ const handler = vi.fn();
+ client.onRunStatus(handler);
+ client.connect();
+ lastSocket().fakeOpen();
+ lastSocket().fakeMessage({
+ event: "goal_status",
+ chat_id: "chat-status",
+ status: "running",
+ started_at: 12_345,
+ });
+ expect(handler).toHaveBeenCalledWith("chat-status", 12_345);
+
+ const lateHandler = vi.fn();
+ client.onRunStatus(lateHandler);
+ expect(lateHandler).toHaveBeenCalledWith("chat-status", 12_345);
+
+ lastSocket().fakeMessage({
+ event: "goal_status",
+ chat_id: "chat-status",
+ status: "idle",
+ });
+ expect(handler).toHaveBeenCalledWith("chat-status", null);
+ expect(lateHandler).toHaveBeenCalledWith("chat-status", null);
+ });
+
it("records goal_state per chat_id without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",