From 1f1f444aa1f244fbdf2e4019325cc7ff1384bae3 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 25 Feb 2026 04:17:18 -0600 Subject: [PATCH] ui: refactor dashboard-v2 structure and behavior --- src/gateway/server-methods/chat.ts | 8 - ui/src/i18n/locales/en.ts | 81 +- ui/src/i18n/locales/pt-BR.ts | 80 +- ui/src/i18n/locales/zh-CN.ts | 80 +- ui/src/i18n/locales/zh-TW.ts | 80 +- ui/src/ui/app-chat.ts | 98 +- ui/src/ui/app-gateway.ts | 110 +- ui/src/ui/app-render.helpers.ts | 455 ++++---- ui/src/ui/app-render.ts | 973 ++++++++++++++--- ui/src/ui/app-settings.ts | 288 +++-- ui/src/ui/app-view-state.ts | 64 +- ui/src/ui/app.ts | 143 ++- ui/src/ui/chat/deleted-messages.ts | 49 + ui/src/ui/chat/grouped-render.ts | 444 +++++++- ui/src/ui/chat/input-history.ts | 49 + ui/src/ui/chat/pinned-messages.ts | 61 ++ ui/src/ui/chat/slash-command-executor.ts | 302 ++++++ ui/src/ui/chat/slash-commands.ts | 217 ++++ ui/src/ui/chat/speech.ts | 225 ++++ ui/src/ui/components/dashboard-header.ts | 34 + ui/src/ui/controllers/agents.ts | 29 +- ui/src/ui/controllers/config.ts | 10 +- ui/src/ui/controllers/health.ts | 62 ++ ui/src/ui/controllers/models.ts | 18 + ui/src/ui/format.ts | 38 + ui/src/ui/icons.ts | 213 ++++ ui/src/ui/markdown.ts | 60 +- ui/src/ui/navigation.ts | 34 +- ui/src/ui/storage.ts | 24 +- ui/src/ui/test-helpers/app-mount.ts | 16 + ui/src/ui/theme-transition.ts | 79 +- ui/src/ui/theme.ts | 72 +- ui/src/ui/tool-labels.ts | 39 + ui/src/ui/types.ts | 117 +-- ui/src/ui/views/agents-panels-overview.ts | 195 ++++ ui/src/ui/views/agents-panels-status-files.ts | 72 +- ui/src/ui/views/agents-panels-tools-skills.ts | 104 +- ui/src/ui/views/agents-utils.ts | 133 ++- ui/src/ui/views/agents.ts | 493 ++++----- ui/src/ui/views/bottom-tabs.ts | 33 + ui/src/ui/views/chat.ts | 994 ++++++++++++++++-- ui/src/ui/views/command-palette.ts | 266 +++++ ui/src/ui/views/config-form.node.ts | 46 + ui/src/ui/views/config.ts | 854 +++++++++------ ui/src/ui/views/debug.ts | 2 +- ui/src/ui/views/instances.ts | 43 +- ui/src/ui/views/login-gate.ts | 132 +++ ui/src/ui/views/overview-attention.ts | 60 ++ ui/src/ui/views/overview-cards.ts | 147 +++ ui/src/ui/views/overview-event-log.ts | 43 + ui/src/ui/views/overview-log-tail.ts | 47 + ui/src/ui/views/overview-quick-actions.ts | 31 + ui/src/ui/views/overview.ts | 255 +++-- ui/src/ui/views/sessions.ts | 343 +++++- ui/src/ui/views/skills.ts | 14 +- ui/vite.config.ts | 18 + 56 files changed, 7219 insertions(+), 1758 deletions(-) create mode 100644 ui/src/ui/chat/deleted-messages.ts create mode 100644 ui/src/ui/chat/input-history.ts create mode 100644 ui/src/ui/chat/pinned-messages.ts create mode 100644 ui/src/ui/chat/slash-command-executor.ts create mode 100644 ui/src/ui/chat/slash-commands.ts create mode 100644 ui/src/ui/chat/speech.ts create mode 100644 ui/src/ui/components/dashboard-header.ts create mode 100644 ui/src/ui/controllers/health.ts create mode 100644 ui/src/ui/controllers/models.ts create mode 100644 ui/src/ui/tool-labels.ts create mode 100644 ui/src/ui/views/agents-panels-overview.ts create mode 100644 ui/src/ui/views/bottom-tabs.ts create mode 100644 ui/src/ui/views/command-palette.ts create mode 100644 ui/src/ui/views/login-gate.ts create mode 100644 ui/src/ui/views/overview-attention.ts create mode 100644 ui/src/ui/views/overview-cards.ts create mode 100644 ui/src/ui/views/overview-event-log.ts create mode 100644 ui/src/ui/views/overview-log-tail.ts create mode 100644 ui/src/ui/views/overview-quick-actions.ts diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1c750ec0db6..12fdb0c4f43 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -179,14 +179,6 @@ function sanitizeChatHistoryMessage(message: unknown): { message: unknown; chang delete entry.details; changed = true; } - if ("usage" in entry) { - delete entry.usage; - changed = true; - } - if ("cost" in entry) { - delete entry.cost; - changed = true; - } if (typeof entry.content === "string") { const stripped = stripInlineDirectiveTagsForDisplay(entry.content); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c4a83017c19..2f9742214a2 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const en: TranslationMap = { common: { - version: "Version", health: "Health", ok: "OK", offline: "Offline", @@ -12,7 +11,9 @@ export const en: TranslationMap = { disabled: "Disabled", na: "n/a", docs: "Docs", + theme: "Theme", resources: "Resources", + search: "Search", }, nav: { chat: "Chat", @@ -21,6 +22,7 @@ export const en: TranslationMap = { settings: "Settings", expand: "Expand sidebar", collapse: "Collapse sidebar", + resize: "Resize sidebar", }, tabs: { agents: "Agents", @@ -34,23 +36,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 +117,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", }, chat: { disconnected: "Disconnected from gateway.", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index d763ca04217..d9b1ebae4d3 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const pt_BR: TranslationMap = { common: { - version: "Versão", health: "Saúde", ok: "OK", offline: "Offline", @@ -13,6 +12,7 @@ export const pt_BR: TranslationMap = { na: "n/a", docs: "Docs", resources: "Recursos", + search: "Pesquisar", }, nav: { chat: "Chat", @@ -21,6 +21,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 +35,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 +118,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", }, chat: { disconnected: "Desconectado do gateway.", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 2cf8ca35ec2..9560c2819e7 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_CN: TranslationMap = { common: { - version: "版本", health: "健康状况", ok: "正常", offline: "离线", @@ -13,6 +12,7 @@ export const zh_CN: TranslationMap = { na: "不适用", docs: "文档", resources: "资源", + search: "搜索", }, nav: { chat: "聊天", @@ -21,6 +21,7 @@ export const zh_CN: TranslationMap = { settings: "设置", expand: "展开侧边栏", collapse: "折叠侧边栏", + resize: "调整侧边栏大小", }, tabs: { agents: "代理", @@ -34,23 +35,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 +115,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: "已断开与网关的连接。", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 6fb48680e75..fcff39bdca3 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts"; export const zh_TW: TranslationMap = { common: { - version: "版本", health: "健康狀況", ok: "正常", offline: "離線", @@ -13,6 +12,7 @@ export const zh_TW: TranslationMap = { na: "不適用", docs: "文檔", resources: "資源", + search: "搜尋", }, nav: { chat: "聊天", @@ -21,6 +21,7 @@ export const zh_TW: TranslationMap = { settings: "設置", expand: "展開側邊欄", collapse: "折疊側邊欄", + resize: "調整側邊欄大小", }, tabs: { agents: "代理", @@ -34,23 +35,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 +115,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: "已斷開與網關的連接。", diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 1e824fb4feb..d6c9cab2639 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -3,14 +3,18 @@ import { scheduleChatScroll } from "./app-scroll.ts"; import { setLastActiveSessionKey } from "./app-settings.ts"; import { resetToolStream } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; +import { executeSlashCommand } from "./chat/slash-command-executor.ts"; +import { parseSlashCommand } from "./chat/slash-commands.ts"; import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; import { loadSessions } from "./controllers/sessions.ts"; -import type { GatewayHelloOk } from "./gateway.ts"; +import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; export type ChatHost = { + client: GatewayBrowserClient | null; + chatMessages: unknown[]; connected: boolean; chatMessage: string; chatAttachments: ChatAttachment[]; @@ -22,10 +26,10 @@ export type ChatHost = { hello: GatewayHelloOk | null; chatAvatarUrl: string | null; refreshSessionsAfterChat: Set; + /** Callback for slash-command side effects that need app-level access. */ + onSlashAction?: (action: string) => void; }; -export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; - export function isChatBusy(host: ChatHost) { return host.chatSending || Boolean(host.chatRunId); } @@ -170,7 +174,6 @@ export async function handleSendChat( const attachmentsToSend = messageOverride == null ? attachments : []; const hasAttachments = attachmentsToSend.length > 0; - // Allow sending with just attachments (no message text required) if (!message && !hasAttachments) { return; } @@ -180,10 +183,24 @@ export async function handleSendChat( return; } + // Intercept local slash commands (/status, /model, /compact, etc.) + const parsed = parseSlashCommand(message); + if (parsed?.command.executeLocal) { + const prevDraft = messageOverride == null ? previousDraft : undefined; + if (messageOverride == null) { + host.chatMessage = ""; + host.chatAttachments = []; + } + await dispatchSlashCommand(host, parsed.command.name, parsed.args, { + previousDraft: prevDraft, + restoreDraft: Boolean(messageOverride && opts?.restoreDraft), + }); + return; + } + const refreshSessions = isChatResetCommand(message); if (messageOverride == null) { host.chatMessage = ""; - // Clear attachments when sending host.chatAttachments = []; } @@ -202,11 +219,80 @@ export async function handleSendChat( }); } +// ── Slash Command Dispatch ── + +async function dispatchSlashCommand( + host: ChatHost, + name: string, + args: string, + sendOpts?: { previousDraft?: string; restoreDraft?: boolean }, +) { + switch (name) { + case "stop": + await handleAbortChat(host); + return; + case "new": + await sendChatMessageNow(host, "/new", { + refreshSessions: true, + previousDraft: sendOpts?.previousDraft, + restoreDraft: sendOpts?.restoreDraft, + }); + return; + case "reset": + await sendChatMessageNow(host, "/reset", { + refreshSessions: true, + previousDraft: sendOpts?.previousDraft, + restoreDraft: sendOpts?.restoreDraft, + }); + return; + case "clear": + host.chatMessages = []; + scheduleChatScroll(host as unknown as Parameters[0]); + return; + case "focus": + host.onSlashAction?.("toggle-focus"); + return; + case "export": + host.onSlashAction?.("export"); + return; + } + + if (!host.client) { + return; + } + + const result = await executeSlashCommand(host.client, host.sessionKey, name, args); + + if (result.content) { + injectCommandResult(host, result.content); + } + + if (result.action === "refresh") { + await refreshChat(host); + } + + scheduleChatScroll(host as unknown as Parameters[0]); +} + +function injectCommandResult(host: ChatHost, content: string) { + host.chatMessages = [ + ...host.chatMessages, + { + role: "system", + content, + timestamp: Date.now(), + }, + ]; +} + export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) { await Promise.all([ loadChatHistory(host as unknown as OpenClawApp), loadSessions(host as unknown as OpenClawApp, { - activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, + activeMinutes: 0, + limit: 0, + includeGlobal: false, + includeUnknown: false, }), refreshChatAvatar(host), ]); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 15b885be26a..03d021e1bf0 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -2,7 +2,7 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE, type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; -import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts"; +import { flushChatQueueForEvent } from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { applySettings, @@ -13,7 +13,7 @@ import { import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; import type { OpenClawApp } from "./app.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; -import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; +import { loadAgents } from "./controllers/agents.ts"; import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts"; @@ -25,20 +25,17 @@ import { parseExecApprovalResolved, removeExecApproval, } from "./controllers/exec-approval.ts"; +import { loadHealthState } from "./controllers/health.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadSessions } from "./controllers/sessions.ts"; -import { - resolveGatewayErrorDetailCode, - type GatewayEventFrame, - type GatewayHelloOk, -} from "./gateway.ts"; +import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts"; import { GatewayBrowserClient } from "./gateway.ts"; import type { Tab } from "./navigation.ts"; import type { UiSettings } from "./storage.ts"; import type { AgentsListResult, PresenceEntry, - HealthSnapshot, + HealthSummary, StatusSummary, UpdateAvailable, } from "./types.ts"; @@ -46,12 +43,10 @@ import type { type GatewayHost = { settings: UiSettings; password: string; - clientInstanceId: string; client: GatewayBrowserClient | null; connected: boolean; hello: GatewayHelloOk | null; lastError: string | null; - lastErrorCode: string | null; onboarding?: boolean; eventLogBuffer: EventLogEntry[]; eventLog: EventLogEntry[]; @@ -62,10 +57,10 @@ type GatewayHost = { agentsLoading: boolean; agentsList: AgentsListResult | null; agentsError: string | null; - toolsCatalogLoading: boolean; - toolsCatalogError: string | null; - toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null; - debugHealth: HealthSnapshot | null; + healthLoading: boolean; + healthResult: HealthSummary | null; + healthError: string | null; + debugHealth: HealthSummary | null; assistantName: string; assistantAvatar: string | null; assistantAgentId: string | null; @@ -166,7 +161,6 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps export function connectGateway(host: GatewayHost) { host.lastError = null; - host.lastErrorCode = null; host.hello = null; host.connected = false; host.execApprovalQueue = []; @@ -184,14 +178,12 @@ export function connectGateway(host: GatewayHost) { clientName: "openclaw-control-ui", clientVersion, mode: "webchat", - instanceId: host.clientInstanceId, onHello: (hello) => { if (host.client !== client) { return; } host.connected = true; host.lastError = null; - host.lastErrorCode = null; host.hello = hello; applySnapshot(host, hello); // Reset orphaned chat run state from before disconnect. @@ -202,29 +194,19 @@ export function connectGateway(host: GatewayHost) { resetToolStream(host as unknown as Parameters[0]); void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp); - void loadToolsCatalog(host as unknown as OpenClawApp); + void loadHealthState(host as unknown as OpenClawApp); void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); }, - onClose: ({ code, reason, error }) => { + onClose: ({ code, reason }) => { if (host.client !== client) { return; } host.connected = false; // Code 1012 = Service Restart (expected during config saves, don't show as error) - host.lastErrorCode = - resolveGatewayErrorDetailCode(error) ?? - (typeof error?.code === "string" ? error.code : null); if (code !== 1012) { - if (error?.message) { - host.lastError = error.message; - return; - } host.lastError = `disconnected (${code}): ${reason || "no reason"}`; - } else { - host.lastError = null; - host.lastErrorCode = null; } }, onEvent: (evt) => { @@ -238,7 +220,6 @@ export function connectGateway(host: GatewayHost) { return; } host.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`; - host.lastErrorCode = null; }, }); host.client = client; @@ -254,48 +235,12 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { } } -function handleTerminalChatEvent( - host: GatewayHost, - payload: ChatEventPayload | undefined, - state: ReturnType, -) { - if (state !== "final" && state !== "error" && state !== "aborted") { - return; - } - resetToolStream(host as unknown as Parameters[0]); - void flushChatQueueForEvent(host as unknown as Parameters[0]); - const runId = payload?.runId; - if (!runId || !host.refreshSessionsAfterChat.has(runId)) { - return; - } - host.refreshSessionsAfterChat.delete(runId); - if (state === "final") { - void loadSessions(host as unknown as OpenClawApp, { - activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, - }); - } -} - -function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) { - if (payload?.sessionKey) { - setLastActiveSessionKey( - host as unknown as Parameters[0], - payload.sessionKey, - ); - } - const state = handleChatEvent(host as unknown as OpenClawApp, payload); - handleTerminalChatEvent(host, payload, state); - if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) { - void loadChatHistory(host as unknown as OpenClawApp); - } -} - function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { host.eventLogBuffer = [ { ts: Date.now(), event: evt.event, payload: evt.payload }, ...host.eventLogBuffer, ].slice(0, 250); - if (host.tab === "debug") { + if (host.tab === "debug" || host.tab === "overview") { host.eventLog = host.eventLogBuffer; } @@ -311,7 +256,33 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { } if (evt.event === "chat") { - handleChatGatewayEvent(host, evt.payload as ChatEventPayload | undefined); + const payload = evt.payload as ChatEventPayload | undefined; + if (payload?.sessionKey) { + setLastActiveSessionKey( + host as unknown as Parameters[0], + payload.sessionKey, + ); + } + const state = handleChatEvent(host as unknown as OpenClawApp, payload); + if (state === "final" || state === "error" || state === "aborted") { + resetToolStream(host as unknown as Parameters[0]); + void flushChatQueueForEvent(host as unknown as Parameters[0]); + const runId = payload?.runId; + if (runId && host.refreshSessionsAfterChat.has(runId)) { + host.refreshSessionsAfterChat.delete(runId); + if (state === "final") { + void loadSessions(host as unknown as OpenClawApp, { + activeMinutes: 0, + limit: 0, + includeGlobal: false, + includeUnknown: false, + }); + } + } + } + if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) { + void loadChatHistory(host as unknown as OpenClawApp); + } return; } @@ -364,7 +335,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { const snapshot = hello.snapshot as | { presence?: PresenceEntry[]; - health?: HealthSnapshot; + health?: HealthSummary; sessionDefaults?: SessionDefaultsSnapshot; updateAvailable?: UpdateAvailable; } @@ -374,6 +345,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { } if (snapshot?.health) { host.debugHealth = snapshot.health; + host.healthResult = snapshot.health; } if (snapshot?.sessionDefaults) { applySessionDefaults(host, snapshot.sessionDefaults); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 68dfbe5e76d..8e212951a3f 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,15 +1,17 @@ -import { html } from "lit"; +import { html, nothing } from "lit"; import { repeat } from "lit/directives/repeat.js"; +import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import { t } from "../i18n/index.ts"; import { refreshChat } from "./app-chat.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { OpenClawApp } from "./app.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts"; +import { loadSessions } from "./controllers/sessions.ts"; import { icons } from "./icons.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts"; -import type { ThemeMode } from "./theme.ts"; +import type { ThemeMode, ThemeName } from "./theme.ts"; import type { SessionsListResult } from "./types.ts"; type SessionDefaultsSnapshot = { @@ -49,10 +51,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string) export function renderTab(state: AppViewState, tab: Tab) { const href = pathForTab(tab, state.basePath); + const isActive = state.tab === tab; + const collapsed = state.settings.navCollapsed; return html` { if ( event.defaultPrevented || @@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) { title=${titleForTab(tab)} > - ${titleForTab(tab)} + ${!collapsed ? html`${titleForTab(tab)}` : nothing} `; } @@ -122,23 +126,52 @@ function renderCronFilterIcon(hiddenCount: number) { `; } +export function renderChatSessionSelect(state: AppViewState) { + const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); + return html` +
+ +
+ `; +} + export function renderChatControls(state: AppViewState) { - const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); const hideCron = state.sessionsHideCron ?? true; const hiddenCronCount = hideCron ? countHiddenCronSessions(state.sessionKey, state.sessionsResult) : 0; - const sessionOptions = resolveSessionOptions( - state.sessionKey, - state.sessionsResult, - mainSessionKey, - hideCron, - ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const focusActive = state.onboarding ? true : state.settings.chatFocusMode; - // Refresh icon const refreshIcon = html` -
-
- - - - -
+
+ ${THEME_MODE_OPTIONS.map( + (opt) => html` + + `, + )}
`; } -function renderSunIcon() { - return html` - - `; -} +export function renderThemeToggle(state: AppViewState) { + const setOpen = (orb: HTMLElement, nextOpen: boolean) => { + orb.classList.toggle("theme-orb--open", nextOpen); + const trigger = orb.querySelector(".theme-orb__trigger"); + const menu = orb.querySelector(".theme-orb__menu"); + if (trigger) { + trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false"); + } + if (menu) { + menu.setAttribute("aria-hidden", nextOpen ? "false" : "true"); + } + }; -function renderMoonIcon() { - return html` - - `; -} + const toggleOpen = (e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (!orb) { + return; + } + const isOpen = orb.classList.contains("theme-orb--open"); + if (isOpen) { + setOpen(orb, false); + } else { + setOpen(orb, true); + const close = (ev: MouseEvent) => { + if (!orb.contains(ev.target as Node)) { + setOpen(orb, false); + document.removeEventListener("click", close); + } + }; + requestAnimationFrame(() => document.addEventListener("click", close)); + } + }; + + const pick = (opt: ThemeOption, e: Event) => { + const orb = (e.currentTarget as HTMLElement).closest(".theme-orb"); + if (orb) { + setOpen(orb, false); + } + if (opt.id !== state.theme) { + const context: ThemeTransitionContext = { element: orb ?? undefined }; + state.setTheme(opt.id, context); + } + }; -function renderMonitorIcon() { return html` - +
+ + +
`; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 97b2271b1bf..51b657243ad 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -1,14 +1,22 @@ import { html, nothing } from "lit"; -import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; +import { + buildAgentMainSessionKey, + parseAgentSessionKey, +} from "../../../src/routing/session-key.js"; import { t } from "../i18n/index.ts"; import { refreshChatAvatar } from "./app-chat.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts"; -import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; +import { + renderChatControls, + renderChatSessionSelect, + renderTab, + renderTopbarThemeModeToggle, +} from "./app-render.helpers.ts"; import type { AppViewState } from "./app-view-state.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts"; -import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; +import { loadAgents } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadChatHistory } from "./controllers/chat.ts"; import { @@ -63,6 +71,7 @@ import { updateSkillEdit, updateSkillEnabled, } from "./controllers/skills.ts"; +import "./components/dashboard-header.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts"; import { icons } from "./icons.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; @@ -70,20 +79,21 @@ import { resolveConfiguredCronModelSuggestions, sortLocaleStrings } from "./view import { renderAgents } from "./views/agents.ts"; import { renderChannels } from "./views/channels.ts"; import { renderChat } from "./views/chat.ts"; +import { renderCommandPalette } from "./views/command-palette.ts"; import { renderConfig } from "./views/config.ts"; import { renderCron } from "./views/cron.ts"; import { renderDebug } from "./views/debug.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; import { renderInstances } from "./views/instances.ts"; +import { renderLoginGate } from "./views/login-gate.ts"; import { renderLogs } from "./views/logs.ts"; import { renderNodes } from "./views/nodes.ts"; import { renderOverview } from "./views/overview.ts"; import { renderSessions } from "./views/sessions.ts"; import { renderSkills } from "./views/skills.ts"; -const AVATAR_DATA_RE = /^data:/i; -const AVATAR_HTTP_RE = /^https?:\/\//i; +const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; const CRON_TIMEZONE_SUGGESTIONS = [ "UTC", @@ -122,6 +132,126 @@ function uniquePreserveOrder(values: string[]): string[] { return output; } +type DismissedUpdateBanner = { + latestVersion: string; + channel: string | null; + dismissedAtMs: number; +}; + +function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { + try { + const raw = localStorage.getItem(UPDATE_BANNER_DISMISS_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if (!parsed || typeof parsed.latestVersion !== "string") { + return null; + } + return { + latestVersion: parsed.latestVersion, + channel: typeof parsed.channel === "string" ? parsed.channel : null, + dismissedAtMs: typeof parsed.dismissedAtMs === "number" ? parsed.dismissedAtMs : Date.now(), + }; + } catch { + return null; + } +} + +function isUpdateBannerDismissed(updateAvailable: unknown): boolean { + const dismissed = loadDismissedUpdateBanner(); + if (!dismissed) { + return false; + } + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + const channel = info && typeof info.channel === "string" ? info.channel : null; + return Boolean( + latestVersion && dismissed.latestVersion === latestVersion && dismissed.channel === channel, + ); +} + +function dismissUpdateBanner(updateAvailable: unknown) { + const info = updateAvailable as { latestVersion?: unknown; channel?: unknown }; + const latestVersion = info && typeof info.latestVersion === "string" ? info.latestVersion : null; + if (!latestVersion) { + return; + } + const channel = info && typeof info.channel === "string" ? info.channel : null; + const payload: DismissedUpdateBanner = { + latestVersion, + channel, + dismissedAtMs: Date.now(), + }; + try { + localStorage.setItem(UPDATE_BANNER_DISMISS_KEY, JSON.stringify(payload)); + } catch { + // ignore + } +} + +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; +const COMMUNICATION_SECTION_KEYS = ["channels", "messages", "broadcast", "talk", "audio"] as const; +const APPEARANCE_SECTION_KEYS = ["__appearance__", "ui", "wizard"] as const; +const AUTOMATION_SECTION_KEYS = [ + "commands", + "hooks", + "bindings", + "cron", + "approvals", + "plugins", +] as const; +const INFRASTRUCTURE_SECTION_KEYS = [ + "gateway", + "web", + "browser", + "nodeHost", + "canvasHost", + "discovery", + "media", +] as const; +const AI_AGENTS_SECTION_KEYS = [ + "agents", + "models", + "skills", + "tools", + "memory", + "session", +] as const; +type CommunicationSectionKey = (typeof COMMUNICATION_SECTION_KEYS)[number]; +type AppearanceSectionKey = (typeof APPEARANCE_SECTION_KEYS)[number]; +type AutomationSectionKey = (typeof AUTOMATION_SECTION_KEYS)[number]; +type InfrastructureSectionKey = (typeof INFRASTRUCTURE_SECTION_KEYS)[number]; +type AiAgentsSectionKey = (typeof AI_AGENTS_SECTION_KEYS)[number]; + +const NAV_WIDTH_MIN = 200; +const NAV_WIDTH_MAX = 400; + +function handleNavResizeStart(e: MouseEvent, state: AppViewState) { + e.preventDefault(); + const startX = e.clientX; + const startWidth = state.settings.navWidth; + + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta))); + state.applySettings({ ...state.settings, navWidth: next }); + }; + + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); +} + function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; const parsed = parseAgentSessionKey(state.sessionKey); @@ -139,16 +269,15 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { } export function renderApp(state: AppViewState) { - const openClawVersion = - (typeof state.hello?.server?.version === "string" && state.hello.server.version.trim()) || - state.updateAvailable?.currentVersion || - t("common.na"); - const availableUpdate = - state.updateAvailable && - state.updateAvailable.latestVersion !== state.updateAvailable.currentVersion - ? state.updateAvailable - : null; - const versionStatusClass = availableUpdate ? "warn" : "ok"; + // Gate: require successful gateway connection before showing the dashboard. + // The gateway URL confirmation overlay is always rendered so URL-param flows still work. + if (!state.connected) { + return html` + ${renderLoginGate(state)} + ${renderGatewayUrlConfirmation(state)} + `; + } + const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null; @@ -221,77 +350,115 @@ export function renderApp(state: AppViewState) { : rawDeliveryToSuggestions; return html` -
+ ${renderCommandPalette({ + open: state.paletteOpen, + query: state.paletteQuery, + activeIndex: state.paletteActiveIndex, + onToggle: () => { + state.paletteOpen = !state.paletteOpen; + }, + onQueryChange: (q) => { + state.paletteQuery = q; + }, + onActiveIndexChange: (i) => { + state.paletteActiveIndex = i; + }, + onNavigate: (tab) => { + state.setTab(tab as import("./navigation.ts").Tab); + }, + onSlashCommand: (cmd) => { + state.setTab("chat" as import("./navigation.ts").Tab); + state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `; + }, + })} +
-
- -
- -
-
OPENCLAW
-
Gateway Dashboard
-
-
-
+ +
-
- - ${t("common.version")} - ${openClawVersion} -
-
- - ${t("common.health")} - ${state.connected ? t("common.ok") : t("common.offline")} -
- ${renderThemeToggle(state)} + ${renderTopbarThemeModeToggle(state)}
-