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-view-state.ts b/ui/src/ui/app-view-state.ts index 2029bd8f8f4..b659c195754 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; skillMessages: Record; 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; 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; loadAssistantIdentity: () => Promise; diff --git a/ui/src/ui/components/dashboard-header.ts b/ui/src/ui/components/dashboard-header.ts new file mode 100644 index 00000000000..d1a1c53b395 --- /dev/null +++ b/ui/src/ui/components/dashboard-header.ts @@ -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` +
+
+ this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))} + > + OpenClaw + + + ${label} +
+
+ +
+
+ `; + } +} diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index 1d3f24bfadf..3b75fb4af21 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -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)}%`; +} diff --git a/ui/src/ui/icons.ts b/ui/src/ui/icons.ts index 1682dcfa9d3..de594541110 100644 --- a/ui/src/ui/icons.ts +++ b/ui/src/ui/icons.ts @@ -50,6 +50,24 @@ export const icons = { `, + sun: html` + + + + + + + + + + + + `, + moon: html` + + + + `, settings: html` `, + panelLeftClose: html` + + + + + + `, + panelLeftOpen: html` + + + + + + `, + chevronDown: html` + + + + `, + chevronRight: html` + + + + `, + externalLink: html` + + + + + `, + send: html` + + + + + `, + stop: html` + + `, + pin: html` + + + + + `, + pinOff: html` + + + + + + `, + download: html` + + + + + + `, + mic: html` + + + + + + `, + micOff: html` + + + + + + + + + `, + volume2: html` + + + + + + `, + volumeOff: html` + + + + + + `, + bookmark: html` + + `, + plus: html` + + + + + `, + terminal: html` + + + + + `, + spark: html` + + + + `, + lobster: html` + + + + + + + + + + + + + + + + + + `, + refresh: html` + + + + + `, + trash: html` + + + + + + + + `, + eye: html` + + + + + `, + eyeOff: html` + + + + + + + `, + moreHorizontal: html` + + + + + + `, + arrowUpDown: html` + + + + + + + `, } as const; export type IconName = keyof typeof icons; diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 8e1bc2c9621..20c8a2a7d8a 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -8,7 +8,19 @@ 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", + "communications", + "appearance", + "automation", + "infrastructure", + "aiAgents", + "debug", + "logs", + ], + }, ] as const; export type Tab = @@ -23,6 +35,11 @@ export type Tab = | "nodes" | "chat" | "config" + | "communications" + | "appearance" + | "automation" + | "infrastructure" + | "aiAgents" | "debug" | "logs"; @@ -38,6 +55,11 @@ const TAB_PATHS: Record = { nodes: "/nodes", chat: "/chat", config: "/config", + communications: "/communications", + appearance: "/appearance", + automation: "/automation", + infrastructure: "/infrastructure", + aiAgents: "/ai-agents", debug: "/debug", logs: "/logs", }; @@ -147,6 +169,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": diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 078c9bccf47..5dc1e0b59a2 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -6,18 +6,20 @@ type PersistedUiSettings = Omit & { 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 (240–400px) navGroupsCollapsed: Record; // 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; + 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 diff --git a/ui/src/ui/theme-transition.ts b/ui/src/ui/theme-transition.ts index fd76706b14a..d770dda463f 100644 --- a/ui/src/ui/theme-transition.ts +++ b/ui/src/ui/theme-transition.ts @@ -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,10 @@ export type ThemeTransitionContext = { }; export type ThemeTransitionOptions = { - nextTheme: ThemeMode; + nextTheme: ResolvedTheme; applyTheme: () => void; context?: ThemeTransitionContext; - currentTheme?: ThemeMode | null; -}; - -type DocumentWithViewTransition = Document & { - startViewTransition?: (callback: () => void) => { finished: Promise }; -}; - -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 +22,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 +38,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); }; diff --git a/ui/src/ui/theme.ts b/ui/src/ui/theme.ts index 480f9dbe51a..deb8d6c1f3e 100644 --- a/ui/src/ui/theme.ts +++ b/ui/src/ui/theme.ts @@ -1,16 +1,74 @@ +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(["claw", "knot", "dash"]); +export const VALID_THEME_MODES = new Set(["system", "light", "dark"]); + +type ThemeSelection = { theme: ThemeName; mode: ThemeMode }; + +const LEGACY_MAP: Record = { + 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; } + +export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme { + const resolvedMode = resolveMode(mode); + if (theme === "claw") { + return resolvedMode === "light" ? "light" : "dark"; + } + if (theme === "knot") { + return resolvedMode === "light" ? "openknot-light" : "openknot"; + } + return resolvedMode === "light" ? "dash-light" : "dash"; +} diff --git a/ui/src/ui/tool-labels.ts b/ui/src/ui/tool-labels.ts new file mode 100644 index 00000000000..e4818c49362 --- /dev/null +++ b/ui/src/ui/tool-labels.ts @@ -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 = { + 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()); +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f87b498100a..10d66068288 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -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; @@ -510,58 +481,17 @@ export type CronStatus = { nextWakeAtMs?: number | null; }; -export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; -export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; -export type CronSortDir = "asc" | "desc"; -export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped"; -export type CronRunsStatusValue = "ok" | "error" | "skipped"; -export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; -export type CronRunScope = "job" | "all"; - export type CronRunLogEntry = { ts: number; jobId: string; - jobName?: string; - status?: CronRunsStatusValue; + status: "ok" | "error" | "skipped"; durationMs?: number; error?: string; summary?: string; - deliveryStatus?: CronDeliveryStatus; - deliveryError?: string; - delivered?: boolean; - runAtMs?: number; - nextRunAtMs?: number; - model?: string; - provider?: string; - usage?: { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - cache_read_tokens?: number; - cache_write_tokens?: number; - }; sessionId?: string; sessionKey?: string; }; -export type CronJobsListResult = { - jobs?: CronJob[]; - total?: number; - offset?: number; - limit?: number; - hasMore?: boolean; - nextOffset?: number | null; -}; - -export type CronRunsResult = { - entries?: CronRunLogEntry[]; - total?: number; - offset?: number; - limit?: number; - hasMore?: boolean; - nextOffset?: number | null; -}; - export type SkillsStatusConfigCheck = { path: string; satisfied: boolean; @@ -615,6 +545,44 @@ export type StatusSummary = Record; export type HealthSnapshot = Record; +/** 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 = { @@ -625,3 +593,16 @@ export type LogEntry = { message?: string | null; meta?: Record | null; }; + +// ── Attention ─────────────────────────────────────── + +export type AttentionSeverity = "error" | "warning" | "info"; + +export type AttentionItem = { + severity: AttentionSeverity; + icon: string; + title: string; + description: string; + href?: string; + external?: boolean; +};