feat(ui): utilities, theming, and i18n updates (slice 2/3 of dashboard-v2) (#41500)

* feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2)

UI utilities and theming improvements extracted from dashboard-v2-structure:

Icons & formatting:
- icons.ts: expanded icon set for new dashboard views
- format.ts: date/number formatting helpers
- tool-labels.ts: human-readable tool name mappings

Theming:
- theme.ts: enhanced theme resolution and system theme support
- theme-transition.ts: simplified transition logic
- storage.ts: theme parsing improvements for settings persistence

Navigation & types:
- navigation.ts: extended tab definitions for dashboard-v2
- app-view-state.ts: expanded view state management
- types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.)

Components:
- components/dashboard-header.ts: reusable header component

i18n:
- Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings

All changes are additive or backwards-compatible. Build passes.
Part of #36853.

* ui: fix theme and locale review regressions

* ui: fix review follow-ups for dashboard tabs

* ui: allowlist locale password placeholder false positives

* ui: fix theme mode and locale regressions

* Vincentkoc code/pr 41500 route fix (#43829)

* UI: keep unfinished settings routes hidden

* UI: normalize light theme data token

* UI: restore cron type compatibility

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Val Alexander
2026-03-12 03:26:39 -05:00
committed by GitHub
parent 658bd54ecf
commit 46cb73da37
23 changed files with 1306 additions and 241 deletions

View File

@@ -12991,7 +12991,7 @@
"filename": "ui/src/i18n/locales/en.ts",
"hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6",
"is_verified": false,
"line_number": 61
"line_number": 74
}
],
"ui/src/i18n/locales/pt-BR.ts": [
@@ -13000,7 +13000,7 @@
"filename": "ui/src/i18n/locales/pt-BR.ts",
"hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243",
"is_verified": false,
"line_number": 61
"line_number": 73
}
],
"vendor/a2ui/README.md": [

View File

@@ -12,7 +12,9 @@ export const en: TranslationMap = {
disabled: "Disabled",
na: "n/a",
docs: "Docs",
theme: "Theme",
resources: "Resources",
search: "Search",
},
nav: {
chat: "Chat",
@@ -21,6 +23,7 @@ export const en: TranslationMap = {
settings: "Settings",
expand: "Expand sidebar",
collapse: "Collapse sidebar",
resize: "Resize sidebar",
},
tabs: {
agents: "Agents",
@@ -34,23 +37,33 @@ export const en: TranslationMap = {
nodes: "Nodes",
chat: "Chat",
config: "Config",
communications: "Communications",
appearance: "Appearance",
automation: "Automation",
infrastructure: "Infrastructure",
aiAgents: "AI & Agents",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Manage agent workspaces, tools, and identities.",
overview: "Gateway status, entry points, and a fast health read.",
channels: "Manage channels and settings.",
instances: "Presence beacons from connected clients and nodes.",
sessions: "Inspect active sessions and adjust per-session defaults.",
usage: "Monitor API usage and costs.",
cron: "Schedule wakeups and recurring agent runs.",
skills: "Manage skill availability and API key injection.",
nodes: "Paired devices, capabilities, and command exposure.",
chat: "Direct gateway chat session for quick interventions.",
config: "Edit ~/.openclaw/openclaw.json safely.",
debug: "Gateway snapshots, events, and manual RPC calls.",
logs: "Live tail of the gateway file logs.",
agents: "Workspaces, tools, identities.",
overview: "Status, entry points, health.",
channels: "Channels and settings.",
instances: "Connected clients and nodes.",
sessions: "Active sessions and defaults.",
usage: "API usage and costs.",
cron: "Wakeups and recurring runs.",
skills: "Skills and API keys.",
nodes: "Paired devices and commands.",
chat: "Gateway chat for quick interventions.",
config: "Edit openclaw.json.",
communications: "Channels, messages, and audio settings.",
appearance: "Theme, UI, and setup wizard settings.",
automation: "Commands, hooks, cron, and plugins.",
infrastructure: "Gateway, web, browser, and media settings.",
aiAgents: "Agents, models, skills, tools, memory, session.",
debug: "Snapshots, events, RPC.",
logs: "Live gateway logs.",
},
overview: {
access: {
@@ -105,6 +118,47 @@ export const en: TranslationMap = {
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
},
connection: {
title: "How to connect",
step1: "Start the gateway on your host machine:",
step2: "Get a tokenized dashboard URL:",
step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.",
step4: "Or generate a reusable token:",
docsHint: "For remote access, Tailscale Serve is recommended. ",
docsLink: "Read the docs →",
},
cards: {
cost: "Cost",
skills: "Skills",
recentSessions: "Recent Sessions",
},
attention: {
title: "Attention",
},
eventLog: {
title: "Event Log",
},
logTail: {
title: "Gateway Logs",
},
quickActions: {
newSession: "New Session",
automation: "Automation",
refreshAll: "Refresh All",
terminal: "Terminal",
},
streamMode: {
active: "Stream mode — values redacted",
disable: "Disable",
},
palette: {
placeholder: "Type a command…",
noResults: "No results",
},
},
login: {
subtitle: "Gateway Dashboard",
passwordPlaceholder: "optional", // pragma: allowlist secret
},
chat: {
disconnected: "Disconnected from gateway.",

View File

@@ -12,7 +12,9 @@ export const pt_BR: TranslationMap = {
disabled: "Desativado",
na: "n/a",
docs: "Docs",
theme: "Tema",
resources: "Recursos",
search: "Pesquisar",
},
nav: {
chat: "Chat",
@@ -21,6 +23,7 @@ export const pt_BR: TranslationMap = {
settings: "Configurações",
expand: "Expandir barra lateral",
collapse: "Recolher barra lateral",
resize: "Redimensionar barra lateral",
},
tabs: {
agents: "Agentes",
@@ -34,23 +37,33 @@ export const pt_BR: TranslationMap = {
nodes: "Nós",
chat: "Chat",
config: "Config",
communications: "Comunicações",
appearance: "Aparência e Configuração",
automation: "Automação",
infrastructure: "Infraestrutura",
aiAgents: "IA e Agentes",
debug: "Debug",
logs: "Logs",
},
subtitles: {
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
channels: "Gerenciar canais e configurações.",
instances: "Beacons de presença de clientes e nós conectados.",
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
usage: "Monitorar uso e custos da API.",
cron: "Agendar despertares e execuções recorrentes de agentes.",
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
config: "Editar ~/.openclaw/openclaw.json com segurança.",
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
agents: "Espaços, ferramentas, identidades.",
overview: "Status, entrada, saúde.",
channels: "Canais e configurações.",
instances: "Clientes e nós conectados.",
sessions: "Sessões ativas e padrões.",
usage: "Uso e custos da API.",
cron: "Despertares e execuções.",
skills: "Habilidades e chaves API.",
nodes: "Dispositivos e comandos.",
chat: "Chat do gateway para intervenções rápidas.",
config: "Editar openclaw.json.",
communications: "Configurações de canais, mensagens e áudio.",
appearance: "Configurações de tema, UI e assistente de configuração.",
automation: "Configurações de comandos, hooks, cron e plugins.",
infrastructure: "Configurações de gateway, web, browser e mídia.",
aiAgents: "Configurações de agentes, modelos, habilidades, ferramentas, memória e sessão.",
debug: "Snapshots, eventos, RPC.",
logs: "Logs ao vivo do gateway.",
},
overview: {
access: {
@@ -107,6 +120,47 @@ export const pt_BR: TranslationMap = {
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
},
connection: {
title: "Como conectar",
step1: "Inicie o gateway na sua máquina host:",
step2: "Obtenha uma URL do painel com token:",
step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.",
step4: "Ou gere um token reutilizável:",
docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ",
docsLink: "Leia a documentação →",
},
cards: {
cost: "Custo",
skills: "Habilidades",
recentSessions: "Sessões Recentes",
},
attention: {
title: "Atenção",
},
eventLog: {
title: "Log de Eventos",
},
logTail: {
title: "Logs do Gateway",
},
quickActions: {
newSession: "Nova Sessão",
automation: "Automação",
refreshAll: "Atualizar Tudo",
terminal: "Terminal",
},
streamMode: {
active: "Modo stream — valores ocultos",
disable: "Desativar",
},
palette: {
placeholder: "Digite um comando…",
noResults: "Sem resultados",
},
},
login: {
subtitle: "Painel do Gateway",
passwordPlaceholder: "opcional", // pragma: allowlist secret
},
chat: {
disconnected: "Desconectado do gateway.",

View File

@@ -12,7 +12,9 @@ export const zh_CN: TranslationMap = {
disabled: "已禁用",
na: "不适用",
docs: "文档",
theme: "主题",
resources: "资源",
search: "搜索",
},
nav: {
chat: "聊天",
@@ -21,6 +23,7 @@ export const zh_CN: TranslationMap = {
settings: "设置",
expand: "展开侧边栏",
collapse: "折叠侧边栏",
resize: "调整侧边栏大小",
},
tabs: {
agents: "代理",
@@ -34,23 +37,33 @@ export const zh_CN: TranslationMap = {
nodes: "节点",
chat: "聊天",
config: "配置",
communications: "通信",
appearance: "外观与设置",
automation: "自动化",
infrastructure: "基础设施",
aiAgents: "AI 与代理",
debug: "调试",
logs: "日志",
},
subtitles: {
agents: "管理代理工作区、工具身份。",
overview: "网关状态、入口点和快速健康读取。",
channels: "管理频道和设置。",
instances: "来自已连接客户端和节点的在线信号。",
sessions: "检查活动会话并调整每个会话的默认设置。",
usage: "监控 API 使用情况和成本。",
cron: "安排唤醒和重复的代理运行。",
skills: "管理技能可用性和 API 密钥注入。",
nodes: "配对设备、功能和命令公开。",
chat: "用于快速干预的直接网关聊天会话。",
config: "安全地编辑 ~/.openclaw/openclaw.json。",
debug: "网关快照、事件和手动 RPC 调用。",
logs: "网关文件日志的实时追踪。",
agents: "工作区、工具身份。",
overview: "状态、入口点、健康。",
channels: "频道和设置。",
instances: "已连接客户端和节点。",
sessions: "活动会话默认设置。",
usage: "API 使用情况和成本。",
cron: "唤醒和重复运行。",
skills: "技能和 API 密钥。",
nodes: "配对设备和命令。",
chat: "网关聊天,快速干预。",
config: "编辑 openclaw.json。",
communications: "频道、消息和音频设置。",
appearance: "主题、界面和设置向导设置。",
automation: "命令、钩子、定时任务和插件设置。",
infrastructure: "网关、Web、浏览器和媒体设置。",
aiAgents: "代理、模型、技能、工具、记忆和会话设置。",
debug: "快照、事件、RPC。",
logs: "实时网关日志。",
},
overview: {
access: {
@@ -104,6 +117,47 @@ export const zh_CN: TranslationMap = {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。",
},
connection: {
title: "如何连接",
step1: "在主机上启动网关:",
step2: "获取带令牌的仪表盘 URL",
step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。",
step4: "或生成可重复使用的令牌:",
docsHint: "如需远程访问,建议使用 Tailscale Serve。",
docsLink: "查看文档 →",
},
cards: {
cost: "费用",
skills: "技能",
recentSessions: "最近会话",
},
attention: {
title: "注意事项",
},
eventLog: {
title: "事件日志",
},
logTail: {
title: "网关日志",
},
quickActions: {
newSession: "新建会话",
automation: "自动化",
refreshAll: "全部刷新",
terminal: "终端",
},
streamMode: {
active: "流模式 — 数据已隐藏",
disable: "禁用",
},
palette: {
placeholder: "输入命令…",
noResults: "无结果",
},
},
login: {
subtitle: "网关仪表盘",
passwordPlaceholder: "可选",
},
chat: {
disconnected: "已断开与网关的连接。",

View File

@@ -12,7 +12,9 @@ export const zh_TW: TranslationMap = {
disabled: "已禁用",
na: "不適用",
docs: "文檔",
theme: "主題",
resources: "資源",
search: "搜尋",
},
nav: {
chat: "聊天",
@@ -21,6 +23,7 @@ export const zh_TW: TranslationMap = {
settings: "設置",
expand: "展開側邊欄",
collapse: "折疊側邊欄",
resize: "調整側邊欄大小",
},
tabs: {
agents: "代理",
@@ -34,23 +37,33 @@ export const zh_TW: TranslationMap = {
nodes: "節點",
chat: "聊天",
config: "配置",
communications: "通訊",
appearance: "外觀與設置",
automation: "自動化",
infrastructure: "基礎設施",
aiAgents: "AI 與代理",
debug: "調試",
logs: "日誌",
},
subtitles: {
agents: "管理代理工作區、工具身份。",
overview: "網關狀態、入口點和快速健康讀取。",
channels: "管理頻道和設置。",
instances: "來自已連接客戶端和節點的在線信號。",
sessions: "檢查活動會話並調整每個會話的默認設置。",
usage: "監控 API 使用情況和成本。",
cron: "安排喚醒和重複的代理運行。",
skills: "管理技能可用性和 API 密鑰注入。",
nodes: "配對設備、功能和命令公開。",
chat: "用於快速干預的直接網關聊天會話。",
config: "安全地編輯 ~/.openclaw/openclaw.json。",
debug: "網關快照、事件和手動 RPC 調用。",
logs: "網關文件日志的實時追蹤。",
agents: "工作區、工具身份。",
overview: "狀態、入口點、健康。",
channels: "頻道和設置。",
instances: "已連接客戶端和節點。",
sessions: "活動會話默認設置。",
usage: "API 使用情況和成本。",
cron: "喚醒和重複運行。",
skills: "技能和 API 密鑰。",
nodes: "配對設備和命令。",
chat: "網關聊天,快速干預。",
config: "編輯 openclaw.json。",
communications: "頻道、消息和音頻設置。",
appearance: "主題、界面和設置向導設置。",
automation: "命令、鉤子、定時任務和插件設置。",
infrastructure: "網關、Web、瀏覽器和媒體設置。",
aiAgents: "代理、模型、技能、工具、記憶和會話設置。",
debug: "快照、事件、RPC。",
logs: "實時網關日誌。",
},
overview: {
access: {
@@ -104,6 +117,47 @@ export const zh_TW: TranslationMap = {
hint: "此頁面為 HTTP因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
stayHttp: "如果您必須保持 HTTP請設置 {config} (僅限令牌)。",
},
connection: {
title: "如何連接",
step1: "在主機上啟動閘道:",
step2: "取得帶令牌的儀表板 URL",
step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。",
step4: "或產生可重複使用的令牌:",
docsHint: "如需遠端存取,建議使用 Tailscale Serve。",
docsLink: "查看文件 →",
},
cards: {
cost: "費用",
skills: "技能",
recentSessions: "最近會話",
},
attention: {
title: "注意事項",
},
eventLog: {
title: "事件日誌",
},
logTail: {
title: "閘道日誌",
},
quickActions: {
newSession: "新建會話",
automation: "自動化",
refreshAll: "全部刷新",
terminal: "終端",
},
streamMode: {
active: "串流模式 — 數據已隱藏",
disable: "禁用",
},
palette: {
placeholder: "輸入指令…",
noResults: "無結果",
},
},
login: {
subtitle: "閘道儀表板",
passwordPlaceholder: "可選",
},
chat: {
disconnected: "已斷開與網關的連接。",

View File

@@ -1,56 +1,100 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { i18n, t } from "../lib/translate.ts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { pt_BR } from "../locales/pt-BR.ts";
import { zh_CN } from "../locales/zh-CN.ts";
import { zh_TW } from "../locales/zh-TW.ts";
type TranslateModule = typeof import("../lib/translate.ts");
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
describe("i18n", () => {
let translate: TranslateModule;
beforeEach(async () => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
translate = await import("../lib/translate.ts");
localStorage.clear();
// Reset to English
await i18n.setLocale("en");
await translate.i18n.setLocale("en");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("should return the key if translation is missing", () => {
expect(t("non.existent.key")).toBe("non.existent.key");
expect(translate.t("non.existent.key")).toBe("non.existent.key");
});
it("should return the correct English translation", () => {
expect(t("common.health")).toBe("Health");
expect(translate.t("common.health")).toBe("Health");
});
it("should replace parameters correctly", () => {
expect(t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
expect(translate.t("overview.stats.cronNext", { time: "10:00" })).toBe("Next wake 10:00");
});
it("should fallback to English if key is missing in another locale", async () => {
// We haven't registered other locales in the test environment yet,
// but the logic should fallback to 'en' map which is always there.
await i18n.setLocale("zh-CN");
await translate.i18n.setLocale("zh-CN");
// Since we don't mock the import, it might fail to load zh-CN,
// but let's assume it falls back to English for now.
expect(t("common.health")).toBeDefined();
expect(translate.t("common.health")).toBeDefined();
});
it("loads translations even when setting the same locale again", async () => {
const internal = i18n as unknown as {
const internal = translate.i18n as unknown as {
locale: string;
translations: Record<string, unknown>;
};
internal.locale = "zh-CN";
delete internal.translations["zh-CN"];
await i18n.setLocale("zh-CN");
expect(t("common.health")).toBe("健康状况");
await translate.i18n.setLocale("zh-CN");
expect(translate.t("common.health")).toBe("健康状况");
});
it("loads saved non-English locale on startup", async () => {
localStorage.setItem("openclaw.i18n.locale", "zh-CN");
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
localStorage.setItem("openclaw.i18n.locale", "zh-CN");
const fresh = await import("../lib/translate.ts");
for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) {
await Promise.resolve();
}
await vi.waitFor(() => {
expect(fresh.i18n.getLocale()).toBe("zh-CN");
});
expect(fresh.i18n.getLocale()).toBe("zh-CN");
expect(fresh.t("common.health")).toBe("健康状况");
});
it("keeps the version label available in shipped locales", () => {
expect((pt_BR.common as { version?: string }).version).toBeTruthy();
expect((zh_CN.common as { version?: string }).version).toBeTruthy();
expect((zh_TW.common as { version?: string }).version).toBeTruthy();
});
});

View File

@@ -490,7 +490,7 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
@@ -498,7 +498,7 @@ export function renderThemeToggle(state: AppViewState) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
}
state.setTheme(next, context);
state.setThemeMode(next, context);
};
return html`
@@ -506,27 +506,27 @@ export function renderThemeToggle(state: AppViewState) {
<div class="theme-toggle__track" role="group" aria-label="Theme">
<span class="theme-toggle__indicator"></span>
<button
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "system" ? "active" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-pressed=${state.themeMode === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-pressed=${state.themeMode === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
class="theme-toggle__button ${state.themeMode === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-pressed=${state.themeMode === "dark"}
aria-label="Dark theme"
title="Dark"
>

View File

@@ -1,26 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setTabFromRoute } from "./app-settings.ts";
import type { Tab } from "./navigation.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
type Tab =
| "agents"
| "overview"
| "channels"
| "instances"
| "sessions"
| "usage"
| "cron"
| "skills"
| "nodes"
| "chat"
| "config"
| "communications"
| "appearance"
| "automation"
| "infrastructure"
| "aiAgents"
| "debug"
| "logs";
type AppSettingsModule = typeof import("./app-settings.ts");
type SettingsHost = {
settings: {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number;
navCollapsed: boolean;
navWidth: number;
navGroupsCollapsed: Record<string, boolean>;
};
theme: ThemeName & ThemeMode;
themeMode: ThemeMode;
themeResolved: import("./theme.ts").ResolvedTheme;
applySessionKey: string;
sessionKey: string;
tab: Tab;
connected: boolean;
chatHasAutoScrolled: boolean;
logsAtBottom: boolean;
eventLog: unknown[];
eventLogBuffer: unknown[];
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
};
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
},
theme: "system",
theme: "claw" as unknown as ThemeName & ThemeMode,
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
@@ -38,33 +114,122 @@ const createHost = (tab: Tab): SettingsHost => ({
});
describe("setTabFromRoute", () => {
let appSettings: AppSettingsModule;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
vi.stubGlobal("window", {
setInterval,
clearInterval,
} as unknown as Window & typeof globalThis);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts and stops log polling based on the tab", () => {
it("starts and stops log polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "logs");
appSettings.setTabFromRoute(host, "logs");
expect(host.logsPollInterval).not.toBeNull();
expect(host.debugPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.logsPollInterval).toBeNull();
});
it("starts and stops debug polling based on the tab", () => {
it("starts and stops debug polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "debug");
appSettings.setTabFromRoute(host, "debug");
expect(host.debugPollInterval).not.toBeNull();
expect(host.logsPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.debugPollInterval).toBeNull();
});
it("re-resolves the active palette when only themeMode changes", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
host.settings.theme = "knot";
host.settings.themeMode = "dark";
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "dark";
host.themeResolved = "openknot";
appSettings.applySettings(host, {
...host.settings,
themeMode: "light",
});
expect(host.theme).toBe("knot");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("openknot-light");
});
it("syncs both theme family and mode from persisted settings", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
host.settings.theme = "dash";
host.settings.themeMode = "light";
appSettings.syncThemeWithSettings(host);
expect(host.theme).toBe("dash");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("dash-light");
});
it("applies named system themes on OS preference changes", async () => {
appSettings ??= await import("./app-settings.ts");
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
const matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: (_name: string, handler: (event: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
removeEventListener: vi.fn(),
});
vi.stubGlobal("matchMedia", matchMedia);
vi.stubGlobal("window", {
setInterval,
clearInterval,
matchMedia,
} as unknown as Window & typeof globalThis);
const host = createHost("chat");
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "system";
appSettings.attachThemeListener(host);
listeners[0]?.({ matches: true } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot");
listeners[0]?.({ matches: false } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot-light");
});
it("normalizes light family themes to the shared light CSS token", async () => {
appSettings ??= await import("./app-settings.ts");
const root = {
dataset: {} as DOMStringMap,
style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string },
};
vi.stubGlobal("document", { documentElement: root } as Document);
const host = createHost("chat");
appSettings.applyResolvedTheme(host, "dash-light");
expect(host.themeResolved).toBe("dash-light");
expect(root.dataset.theme).toBe("light");
expect(root.style.colorScheme).toBe("light");
});
});

View File

@@ -36,13 +36,21 @@ import {
} from "./navigation.ts";
import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
import {
colorSchemeForTheme,
dataThemeForTheme,
resolveTheme,
type ResolvedTheme,
type ThemeMode,
type ThemeName,
} from "./theme.ts";
import type { AgentsListResult } from "./types.ts";
type SettingsHost = {
settings: UiSettings;
password?: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
sessionKey: string;
@@ -69,9 +77,10 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
};
host.settings = normalized;
saveSettings(normalized);
if (next.theme !== host.theme) {
if (next.theme !== host.theme || next.themeMode !== host.themeMode) {
host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme));
host.themeMode = next.themeMode;
applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode));
}
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@@ -166,17 +175,35 @@ export function setTab(host: SettingsHost, next: Tab) {
applyTabSelection(host, next, { refreshPolicy: "always", syncUrl: true });
}
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
applyResolvedTheme(host, resolveTheme(next));
applyResolvedTheme(host, resolveTheme(next, host.themeMode));
};
startThemeTransition({
nextTheme: next,
nextTheme: resolveTheme(next, host.themeMode),
applyTheme,
context,
currentTheme: host.theme,
currentTheme: host.themeResolved,
});
}
export function setThemeMode(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
const applyTheme = () => {
host.themeMode = next;
applySettings(host, { ...host.settings, themeMode: next });
applyResolvedTheme(host, resolveTheme(host.theme, next));
};
startThemeTransition({
nextTheme: resolveTheme(host.theme, next),
applyTheme,
context,
currentTheme: host.themeResolved,
});
}
@@ -262,8 +289,9 @@ export function inferBasePath() {
}
export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme));
host.theme = host.settings.theme;
host.themeMode = host.settings.themeMode;
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@@ -272,8 +300,8 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
return;
}
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
root.dataset.theme = dataThemeForTheme(resolved);
root.style.colorScheme = colorSchemeForTheme(resolved);
}
export function attachThemeListener(host: SettingsHost) {
@@ -282,10 +310,10 @@ export function attachThemeListener(host: SettingsHost) {
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") {
if (host.themeMode !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light");
applyResolvedTheme(host, resolveTheme(host.theme, event.matches ? "dark" : "light"));
};
if (typeof host.themeMedia.addEventListener === "function") {
host.themeMedia.addEventListener("change", host.themeMediaHandler);

View File

@@ -9,17 +9,19 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode } from "./theme.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
AttentionItem,
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
HealthSnapshot,
HealthSummary,
LogEntry,
LogLevel,
ModelCatalogEntry,
NostrProfile,
PresenceEntry,
SessionsUsageResult,
@@ -27,8 +29,8 @@ import type {
SessionUsageTimeSeries,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
ToolsCatalogResult,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@@ -37,12 +39,16 @@ import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
settings: UiSettings;
password: string;
loginShowGatewayToken: boolean;
loginShowGatewayPassword: boolean;
tab: Tab;
onboarding: boolean;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
themeOrder: ThemeName[];
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
@@ -110,6 +116,26 @@ export type AppViewState = {
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
communicationsFormMode: "form" | "raw";
communicationsSearchQuery: string;
communicationsActiveSection: string | null;
communicationsActiveSubsection: string | null;
appearanceFormMode: "form" | "raw";
appearanceSearchQuery: string;
appearanceActiveSection: string | null;
appearanceActiveSubsection: string | null;
automationFormMode: "form" | "raw";
automationSearchQuery: string;
automationActiveSection: string | null;
automationActiveSubsection: string | null;
infrastructureFormMode: "form" | "raw";
infrastructureSearchQuery: string;
infrastructureActiveSection: string | null;
infrastructureActiveSubsection: string | null;
aiAgentsFormMode: "form" | "raw";
aiAgentsSearchQuery: string;
aiAgentsActiveSection: string | null;
aiAgentsActiveSubsection: string | null;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
@@ -155,6 +181,12 @@ export type AppViewState = {
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsHideCron: boolean;
sessionsSearchQuery: string;
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
sessionsSortDir: "asc" | "desc";
sessionsPage: number;
sessionsPageSize: number;
sessionsActionsOpenKey: string | null;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;
@@ -233,10 +265,13 @@ export type AppViewState = {
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHealth: HealthSummary | null;
debugModels: ModelCatalogEntry[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
@@ -256,11 +291,21 @@ export type AppViewState = {
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
attentionItems: AttentionItem[];
paletteOpen: boolean;
paletteQuery: string;
paletteActiveIndex: number;
streamMode: boolean;
overviewShowGatewayToken: boolean;
overviewShowGatewayPassword: boolean;
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;

View File

@@ -42,6 +42,7 @@ import {
loadOverview as loadOverviewInternal,
setTab as setTabInternal,
setTheme as setThemeInternal,
setThemeMode as setThemeModeInternal,
onPopState as onPopStateInternal,
} from "./app-settings.ts";
import {
@@ -61,7 +62,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@@ -123,7 +124,8 @@ export class OpenClawApp extends LitElement {
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() theme: ThemeName = this.settings.theme;
@state() themeMode: ThemeMode = this.settings.themeMode;
@state() themeResolved: ResolvedTheme = "dark";
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@@ -471,10 +473,18 @@ export class OpenClawApp extends LitElement {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
}
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
setThemeModeInternal(
this as unknown as Parameters<typeof setThemeModeInternal>[0],
next,
context,
);
}
async loadOverview() {
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
}

View File

@@ -0,0 +1,34 @@
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { titleForTab, type Tab } from "../navigation.js";
@customElement("dashboard-header")
export class DashboardHeader extends LitElement {
override createRenderRoot() {
return this;
}
@property() tab: Tab = "overview";
override render() {
const label = titleForTab(this.tab);
return html`
<div class="dashboard-header">
<div class="dashboard-header__breadcrumb">
<span
class="dashboard-header__breadcrumb-link"
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
>
OpenClaw
</span>
<span class="dashboard-header__breadcrumb-sep"></span>
<span class="dashboard-header__breadcrumb-current">${label}</span>
</div>
<div class="dashboard-header__actions">
<slot></slot>
</div>
</div>
`;
}
}

View File

@@ -58,3 +58,41 @@ export function parseList(input: string): string[] {
export function stripThinkingTags(value: string): string {
return stripAssistantInternalScaffolding(value);
}
export function formatCost(cost: number | null | undefined, fallback = "$0.00"): string {
if (cost == null || !Number.isFinite(cost)) {
return fallback;
}
if (cost === 0) {
return "$0.00";
}
if (cost < 0.01) {
return `$${cost.toFixed(4)}`;
}
if (cost < 1) {
return `$${cost.toFixed(3)}`;
}
return `$${cost.toFixed(2)}`;
}
export function formatTokens(tokens: number | null | undefined, fallback = "0"): string {
if (tokens == null || !Number.isFinite(tokens)) {
return fallback;
}
if (tokens < 1000) {
return String(Math.round(tokens));
}
if (tokens < 1_000_000) {
const k = tokens / 1000;
return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
}
const m = tokens / 1_000_000;
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
}
export function formatPercent(value: number | null | undefined, fallback = "—"): string {
if (value == null || !Number.isFinite(value)) {
return fallback;
}
return `${(value * 100).toFixed(1)}%`;
}

View File

@@ -50,6 +50,24 @@ export const icons = {
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
`,
sun: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
`,
moon: html`
<svg viewBox="0 0 24 24">
<path d="M12 3a6.5 6.5 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
`,
settings: html`
<svg viewBox="0 0 24 24">
<path
@@ -228,6 +246,201 @@ export const icons = {
/>
</svg>
`,
panelLeftClose: html`
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" stroke-linecap="round" />
<path d="M16 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
panelLeftOpen: html`
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" stroke-linecap="round" />
<path d="M14 10l3 2-3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
chevronDown: html`
<svg viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
chevronRight: html`
<svg viewBox="0 0 24 24">
<path d="M9 18l6-6-6-6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
externalLink: html`
<svg viewBox="0 0 24 24">
<path
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M15 3h6v6M10 14L21 3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
send: html`
<svg viewBox="0 0 24 24">
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
`,
stop: html`
<svg viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1" /></svg>
`,
pin: html`
<svg viewBox="0 0 24 24">
<line x1="12" x2="12" y1="17" y2="22" />
<path
d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
/>
</svg>
`,
pinOff: html`
<svg viewBox="0 0 24 24">
<line x1="2" x2="22" y1="2" y2="22" />
<line x1="12" x2="12" y1="17" y2="22" />
<path
d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0-.39.04"
/>
</svg>
`,
download: html`
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</svg>
`,
mic: html`
<svg viewBox="0 0 24 24">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
`,
micOff: html`
<svg viewBox="0 0 24 24">
<line x1="2" x2="22" y1="2" y2="22" />
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
<path d="M5 10v2a7 7 0 0 0 12 5" />
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
`,
volume2: html`
<svg viewBox="0 0 24 24">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
`,
volumeOff: html`
<svg viewBox="0 0 24 24">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<line x1="22" x2="16" y1="9" y2="15" />
<line x1="16" x2="22" y1="9" y2="15" />
</svg>
`,
bookmark: html`
<svg viewBox="0 0 24 24"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /></svg>
`,
plus: html`
<svg viewBox="0 0 24 24">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
`,
terminal: html`
<svg viewBox="0 0 24 24">
<polyline points="4 17 10 11 4 5" />
<line x1="12" x2="20" y1="19" y2="19" />
</svg>
`,
spark: html`
<svg viewBox="0 0 24 24">
<path
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"
/>
</svg>
`,
lobster: html`
<svg viewBox="0 0 120 120" fill="none">
<defs>
<linearGradient id="lob-g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ff4d4d" />
<stop offset="100%" stop-color="#991b1b" />
</linearGradient>
</defs>
<path
d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z"
fill="url(#lob-g)"
/>
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#lob-g)" />
<path
d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z"
fill="url(#lob-g)"
/>
<path d="M45 15Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<path d="M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
<circle cx="45" cy="35" r="6" fill="#050810" />
<circle cx="75" cy="35" r="6" fill="#050810" />
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
</svg>
`,
refresh: html`
<svg viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
`,
trash: html`
<svg viewBox="0 0 24 24">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
<line x1="10" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</svg>
`,
eye: html`
<svg viewBox="0 0 24 24">
<path
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
/>
<circle cx="12" cy="12" r="3" />
</svg>
`,
eyeOff: html`
<svg viewBox="0 0 24 24">
<path
d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
/>
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
<path
d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
/>
<path d="m2 2 20 20" />
</svg>
`,
moreHorizontal: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="1.5" />
<circle cx="6" cy="12" r="1.5" />
<circle cx="18" cy="12" r="1.5" />
</svg>
`,
arrowUpDown: html`
<svg viewBox="0 0 24 24">
<path d="m21 16-4 4-4-4" />
<path d="M17 20V4" />
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
</svg>
`,
} as const;
export type IconName = keyof typeof icons;

View File

@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type NavigationModule = typeof import("./navigation.ts");
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
describe("TAB_GROUPS", () => {
let navigation: NavigationModule;
beforeEach(async () => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
navigation = await import("./navigation.ts");
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("does not expose unfinished settings slices in the sidebar", () => {
const settings = navigation.TAB_GROUPS.find((group) => group.label === "settings");
expect(settings?.tabs).toEqual(["config", "debug", "logs"]);
});
it("does not route directly into unfinished settings slices", () => {
expect(navigation.tabFromPath("/communications")).toBeNull();
expect(navigation.tabFromPath("/appearance")).toBeNull();
expect(navigation.tabFromPath("/automation")).toBeNull();
expect(navigation.tabFromPath("/infrastructure")).toBeNull();
expect(navigation.tabFromPath("/ai-agents")).toBeNull();
expect(navigation.tabFromPath("/config")).toBe("config");
});
});

View File

@@ -8,7 +8,10 @@ export const TAB_GROUPS = [
tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"],
},
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
{ label: "settings", tabs: ["config", "debug", "logs"] },
{
label: "settings",
tabs: ["config", "debug", "logs"],
},
] as const;
export type Tab =
@@ -23,6 +26,11 @@ export type Tab =
| "nodes"
| "chat"
| "config"
| "communications"
| "appearance"
| "automation"
| "infrastructure"
| "aiAgents"
| "debug"
| "logs";
@@ -38,11 +46,28 @@ const TAB_PATHS: Record<Tab, string> = {
nodes: "/nodes",
chat: "/chat",
config: "/config",
communications: "/communications",
appearance: "/appearance",
automation: "/automation",
infrastructure: "/infrastructure",
aiAgents: "/ai-agents",
debug: "/debug",
logs: "/logs",
};
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
const HIDDEN_SETTINGS_TABS = new Set<Tab>([
"communications",
"appearance",
"automation",
"infrastructure",
"aiAgents",
]);
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS)
.filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab))
.map(([tab, path]) => [path, tab as Tab]),
);
export function normalizeBasePath(basePath: string): string {
if (!basePath) {
@@ -147,6 +172,16 @@ export function iconForTab(tab: Tab): IconName {
return "monitor";
case "config":
return "settings";
case "communications":
return "send";
case "appearance":
return "spark";
case "automation":
return "terminal";
case "infrastructure":
return "globe";
case "aiAgents":
return "brain";
case "debug":
return "bug";
case "logs":

View File

@@ -128,11 +128,13 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "agent",
lastActiveSessionKey: "agent",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(sessionStorage.length).toBe(0);
@@ -151,11 +153,13 @@ describe("loadSettings default gateway URL derivation", () => {
token: "session-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
@@ -178,11 +182,13 @@ describe("loadSettings default gateway URL derivation", () => {
token: "gateway-a-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
@@ -192,11 +198,13 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: "wss://other-gateway.example:8443/openclaw",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
}),
);
@@ -220,11 +228,13 @@ describe("loadSettings default gateway URL derivation", () => {
token: "memory-only-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(loadSettings()).toMatchObject({
@@ -236,11 +246,13 @@ describe("loadSettings default gateway URL derivation", () => {
gatewayUrl: "wss://gateway.example:8443/openclaw",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(sessionStorage.length).toBe(1);
@@ -259,11 +271,13 @@ describe("loadSettings default gateway URL derivation", () => {
token: "stale-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
saveSettings({
@@ -271,15 +285,47 @@ describe("loadSettings default gateway URL derivation", () => {
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(loadSettings().token).toBe("");
expect(sessionStorage.length).toBe(0);
});
it("persists themeMode and navWidth alongside the selected theme", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "dash",
themeMode: "light",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 320,
navGroupsCollapsed: {},
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
theme: "dash",
themeMode: "light",
navWidth: 320,
});
});
});

View File

@@ -6,18 +6,20 @@ type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
import { isSupportedLocale } from "../i18n/index.ts";
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
import type { ThemeMode } from "./theme.ts";
import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts";
export type UiSettings = {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (200400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string;
};
@@ -106,11 +108,13 @@ export function loadSettings(): UiSettings {
token: loadSessionToken(defaultUrl),
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
};
@@ -120,6 +124,10 @@ export function loadSettings(): UiSettings {
return defaults;
}
const parsed = JSON.parse(raw) as Partial<UiSettings>;
const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode,
);
const settings = {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
@@ -140,10 +148,8 @@ export function loadSettings(): UiSettings {
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme:
parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
? parsed.theme
: defaults.theme,
theme,
themeMode: mode,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:
@@ -158,6 +164,10 @@ export function loadSettings(): UiSettings {
: defaults.splitRatio,
navCollapsed:
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
navWidth:
typeof parsed.navWidth === "number" && parsed.navWidth >= 200 && parsed.navWidth <= 400
? parsed.navWidth
: defaults.navWidth,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
@@ -184,10 +194,12 @@ function persistSettings(next: UiSettings) {
sessionKey: next.sessionKey,
lastActiveSessionKey: next.lastActiveSessionKey,
theme: next.theme,
themeMode: next.themeMode,
chatFocusMode: next.chatFocusMode,
chatShowThinking: next.chatShowThinking,
splitRatio: next.splitRatio,
navCollapsed: next.navCollapsed,
navWidth: next.navWidth,
navGroupsCollapsed: next.navGroupsCollapsed,
...(next.locale ? { locale: next.locale } : {}),
};

View File

@@ -1,4 +1,4 @@
import type { ThemeMode } from "./theme.ts";
import type { ResolvedTheme } from "./theme.ts";
export type ThemeTransitionContext = {
element?: HTMLElement | null;
@@ -7,34 +7,12 @@ export type ThemeTransitionContext = {
};
export type ThemeTransitionOptions = {
nextTheme: ThemeMode;
nextTheme: ResolvedTheme;
applyTheme: () => void;
// Retained so callers from stacked slices can keep passing pointer metadata
// while theme switching remains an immediate, non-animated update here.
context?: ThemeTransitionContext;
currentTheme?: ThemeMode | null;
};
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void) => { finished: Promise<void> };
};
const clamp01 = (value: number) => {
if (Number.isNaN(value)) {
return 0.5;
}
if (value <= 0) {
return 0;
}
if (value >= 1) {
return 1;
}
return value;
};
const hasReducedMotionPreference = () => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ?? false;
currentTheme?: ResolvedTheme | null;
};
const cleanupThemeTransition = (root: HTMLElement) => {
@@ -46,10 +24,12 @@ const cleanupThemeTransition = (root: HTMLElement) => {
export const startThemeTransition = ({
nextTheme,
applyTheme,
context,
currentTheme,
}: ThemeTransitionOptions) => {
if (currentTheme === nextTheme) {
// Even when the resolved palette is unchanged (e.g. system->dark on a dark OS),
// we still need to persist the user's explicit selection immediately.
applyTheme();
return;
}
@@ -60,50 +40,7 @@ export const startThemeTransition = ({
}
const root = documentReference.documentElement;
const document_ = documentReference as DocumentWithViewTransition;
const prefersReducedMotion = hasReducedMotionPreference();
const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion;
if (canUseViewTransition) {
let xPercent = 0.5;
let yPercent = 0.5;
if (
context?.pointerClientX !== undefined &&
context?.pointerClientY !== undefined &&
typeof window !== "undefined"
) {
xPercent = clamp01(context.pointerClientX / window.innerWidth);
yPercent = clamp01(context.pointerClientY / window.innerHeight);
} else if (context?.element) {
const rect = context.element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0 && typeof window !== "undefined") {
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
}
}
root.style.setProperty("--theme-switch-x", `${xPercent * 100}%`);
root.style.setProperty("--theme-switch-y", `${yPercent * 100}%`);
root.classList.add("theme-transition");
try {
const transition = document_.startViewTransition?.(() => {
applyTheme();
});
if (transition?.finished) {
void transition.finished.finally(() => cleanupThemeTransition(root));
} else {
cleanupThemeTransition(root);
}
} catch {
cleanupThemeTransition(root);
applyTheme();
}
return;
}
// Theme updates should be visible immediately on click with no transition lag.
applyTheme();
cleanupThemeTransition(root);
};

38
ui/src/ui/theme.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { colorSchemeForTheme, parseThemeSelection, resolveTheme } from "./theme.ts";
describe("resolveTheme", () => {
it("keeps the legacy mode-only signature working for existing callers", () => {
expect(resolveTheme("dark")).toBe("dark");
expect(resolveTheme("light")).toBe("light");
});
it("resolves named theme families when mode is provided", () => {
expect(resolveTheme("knot", "dark")).toBe("openknot");
expect(resolveTheme("dash", "light")).toBe("dash-light");
});
it("uses system preference when a named theme omits mode", () => {
vi.stubGlobal("matchMedia", vi.fn().mockReturnValue({ matches: true }));
expect(resolveTheme("knot")).toBe("openknot-light");
vi.unstubAllGlobals();
});
it("maps resolved theme families back to valid CSS color-scheme values", () => {
expect(colorSchemeForTheme("openknot")).toBe("dark");
expect(colorSchemeForTheme("dash-light")).toBe("light");
});
});
describe("parseThemeSelection", () => {
it("maps legacy stored values onto theme + mode", () => {
expect(parseThemeSelection("system", undefined)).toEqual({
theme: "claw",
mode: "system",
});
expect(parseThemeSelection("fieldmanual", undefined)).toEqual({
theme: "dash",
mode: "dark",
});
});
});

View File

@@ -1,16 +1,103 @@
export type ThemeName = "claw" | "knot" | "dash";
export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export type ResolvedTheme =
| "dark"
| "light"
| "openknot"
| "openknot-light"
| "dash"
| "dash-light";
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
export const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash"]);
export const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
type ThemeSelection = { theme: ThemeName; mode: ThemeMode };
const LEGACY_MAP: Record<string, ThemeSelection> = {
defaultTheme: { theme: "claw", mode: "dark" },
docsTheme: { theme: "claw", mode: "light" },
lightTheme: { theme: "knot", mode: "dark" },
landingTheme: { theme: "knot", mode: "dark" },
newTheme: { theme: "knot", mode: "dark" },
dark: { theme: "claw", mode: "dark" },
light: { theme: "claw", mode: "light" },
openknot: { theme: "knot", mode: "dark" },
fieldmanual: { theme: "dash", mode: "dark" },
clawdash: { theme: "dash", mode: "light" },
system: { theme: "claw", mode: "system" },
};
export function prefersLightScheme(): boolean {
if (typeof globalThis.matchMedia !== "function") {
return false;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return globalThis.matchMedia("(prefers-color-scheme: light)").matches;
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
export function resolveSystemTheme(): ResolvedTheme {
return prefersLightScheme() ? "light" : "dark";
}
export function parseThemeSelection(
themeRaw: unknown,
modeRaw: unknown,
): { theme: ThemeName; mode: ThemeMode } {
const theme = typeof themeRaw === "string" ? themeRaw : "";
const mode = typeof modeRaw === "string" ? modeRaw : "";
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
? (theme as ThemeName)
: (LEGACY_MAP[theme]?.theme ?? "claw");
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
? (mode as ThemeMode)
: (LEGACY_MAP[theme]?.mode ?? "system");
return { theme: normalizedTheme, mode: normalizedMode };
}
function resolveMode(mode: ThemeMode): "light" | "dark" {
if (mode === "system") {
return getSystemTheme();
return prefersLightScheme() ? "light" : "dark";
}
return mode;
}
function normalizeThemeArgs(
themeOrMode: ThemeName | ThemeMode,
mode: ThemeMode | undefined,
): { theme: ThemeName; mode: ThemeMode } {
if (VALID_THEME_NAMES.has(themeOrMode as ThemeName)) {
return {
theme: themeOrMode as ThemeName,
mode: mode ?? "system",
};
}
return {
theme: "claw",
mode: themeOrMode as ThemeMode,
};
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme;
export function resolveTheme(theme: ThemeName, mode?: ThemeMode): ResolvedTheme;
export function resolveTheme(themeOrMode: ThemeName | ThemeMode, mode?: ThemeMode): ResolvedTheme {
const normalized = normalizeThemeArgs(themeOrMode, mode);
const resolvedMode = resolveMode(normalized.mode);
if (normalized.theme === "claw") {
return resolvedMode === "light" ? "light" : "dark";
}
if (normalized.theme === "knot") {
return resolvedMode === "light" ? "openknot-light" : "openknot";
}
return resolvedMode === "light" ? "dash-light" : "dash";
}
export function colorSchemeForTheme(theme: ResolvedTheme): "light" | "dark" {
return theme === "light" || theme === "openknot-light" || theme === "dash-light"
? "light"
: "dark";
}
export function dataThemeForTheme(theme: ResolvedTheme): ResolvedTheme | "light" {
return colorSchemeForTheme(theme) === "light" ? "light" : theme;
}

39
ui/src/ui/tool-labels.ts Normal file
View File

@@ -0,0 +1,39 @@
/**
* Map raw tool names to human-friendly labels for the chat UI.
* Unknown tools are title-cased with underscores replaced by spaces.
*/
export const TOOL_LABELS: Record<string, string> = {
exec: "Run Command",
bash: "Run Command",
read: "Read File",
write: "Write File",
edit: "Edit File",
apply_patch: "Apply Patch",
web_search: "Web Search",
web_fetch: "Fetch Page",
browser: "Browser",
message: "Send Message",
image: "Generate Image",
canvas: "Canvas",
cron: "Cron",
gateway: "Gateway",
nodes: "Nodes",
memory_search: "Search Memory",
memory_get: "Get Memory",
session_status: "Session Status",
sessions_list: "List Sessions",
sessions_history: "Session History",
sessions_send: "Send to Session",
sessions_spawn: "Spawn Session",
agents_list: "List Agents",
};
export function friendlyToolName(raw: string): string {
const mapped = TOOL_LABELS[raw];
if (mapped) {
return mapped;
}
// Title-case fallback: "some_tool_name" → "Some Tool Name"
return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}

View File

@@ -6,7 +6,7 @@ import type {
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
export type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type ChannelsStatusSnapshot = {
ts: number;
@@ -329,35 +329,6 @@ export type AgentsListResult = {
agents: GatewayAgentRow[];
};
export type ToolCatalogProfile = {
id: "minimal" | "coding" | "messaging" | "full";
label: string;
};
export type ToolCatalogEntry = {
id: string;
label: string;
description: string;
source: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">;
};
export type ToolCatalogGroup = {
id: string;
label: string;
source: "core" | "plugin";
pluginId?: string;
tools: ToolCatalogEntry[];
};
export type ToolsCatalogResult = {
agentId: string;
profiles: ToolCatalogProfile[];
groups: ToolCatalogGroup[];
};
export type AgentIdentityResult = {
agentId: string;
name: string;
@@ -616,6 +587,44 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */
export type HealthSummary = {
ok: boolean;
ts: number;
durationMs: number;
heartbeatSeconds: number;
defaultAgentId: string;
agents: Array<{ id: string; name?: string }>;
sessions: {
path: string;
count: number;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
};
/** A model entry returned by the gateway model-catalog endpoint. */
export type ModelCatalogEntry = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
export type ToolCatalogProfile =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogProfile;
export type ToolCatalogEntry =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogEntry;
export type ToolCatalogGroup =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogGroup;
export type ToolsCatalogResult =
import("../../../src/gateway/protocol/schema/types.js").ToolsCatalogResult;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
@@ -626,3 +635,16 @@ export type LogEntry = {
message?: string | null;
meta?: Record<string, unknown> | null;
};
// ── Attention ───────────────────────────────────────
export type AttentionSeverity = "error" | "warning" | "info";
export type AttentionItem = {
severity: AttentionSeverity;
icon: string;
title: string;
description: string;
href?: string;
external?: boolean;
};