ui: refactor dashboard-v2 structure and behavior

This commit is contained in:
Val Alexander
2026-02-25 04:17:18 -06:00
parent 688b72e158
commit 1f1f444aa1
56 changed files with 7219 additions and 1758 deletions

View File

@@ -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);

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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: "已断开与网关的连接。",

View File

@@ -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: "已斷開與網關的連接。",

View File

@@ -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<string>;
/** 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<typeof scheduleChatScroll>[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<typeof scheduleChatScroll>[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),
]);

View File

@@ -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<typeof resetToolStream>[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<typeof refreshActiveTab>[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<typeof handleChatEvent>,
) {
if (state !== "final" && state !== "error" && state !== "aborted") {
return;
}
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[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<typeof setLastActiveSessionKey>[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<typeof setLastActiveSessionKey>[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<typeof resetToolStream>[0]);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[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);

View File

@@ -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`
<a
href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}"
class="nav-item ${isActive ? "nav-item--active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
@@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
<span class="nav-item__text">${titleForTab(tab)}</span>
${!collapsed ? html`<span class="nav-item__text">${titleForTab(tab)}</span>` : nothing}
</a>
`;
}
@@ -122,23 +126,52 @@ function renderCronFilterIcon(hiddenCount: number) {
`;
}
export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
switchChatSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key} title=${entry.title}>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
</div>
`;
}
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`
<svg
width="18"
@@ -174,43 +207,6 @@ export function renderChatControls(state: AppViewState) {
`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
next,
true,
);
void loadChatHistory(state as unknown as ChatState);
}}
>
${repeat(
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key} title=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button
class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected}
@@ -291,23 +287,36 @@ export function renderChatControls(state: AppViewState) {
`;
}
function resolveMainSessionKey(
hello: AppViewState["hello"],
sessions: SessionsListResult | null,
): string | null {
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
if (mainSessionKey) {
return mainSessionKey;
}
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
if (mainKey) {
return mainKey;
}
if (sessions?.sessions?.some((row) => row.key === "main")) {
return "main";
}
return null;
function switchChatSession(state: AppViewState, nextSessionKey: string) {
state.sessionKey = nextSessionKey;
state.chatMessage = "";
state.chatStream = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: nextSessionKey,
lastActiveSessionKey: nextSessionKey,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
nextSessionKey,
true,
);
void loadChatHistory(state as unknown as ChatState);
void refreshSessionOptions(state);
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
includeGlobal: false,
includeUnknown: false,
});
}
/* ── Channel display labels ────────────────────────────── */
@@ -431,51 +440,71 @@ export function isCronSessionKey(key: string): boolean {
return rest.startsWith("cron:");
}
function resolveSessionOptions(
type SessionOptionEntry = {
key: string;
label: string;
title: string;
};
type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string,
sessions: SessionsListResult | null,
mainSessionKey?: string | null,
hideCron = false,
) {
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add main session key first
if (mainSessionKey) {
seen.add(mainSessionKey);
options.push({
key: mainSessionKey,
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
});
): SessionOptionGroup[] {
const rows = sessions?.sessions ?? [];
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
for (const row of rows) {
byKey.set(row.key, row);
}
// Add current session key next — always include it even if it's a cron session,
// so the active session is never silently dropped from the select.
if (!seen.has(sessionKey)) {
seen.add(sessionKey);
options.push({
key: sessionKey,
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
});
}
// Add sessions from the result, optionally filtering out cron sessions.
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key) && !(hideCron && isCronSessionKey(s.key))) {
seen.add(s.key);
options.push({
key: s.key,
displayName: resolveSessionDisplayName(s.key, s),
});
}
const seenKeys = new Set<string>();
const groups = new Map<string, SessionOptionGroup>();
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
const existing = groups.get(groupId);
if (existing) {
return existing;
}
}
const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
return options;
const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${parsed.agentId.toLowerCase()}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
title: key,
});
};
for (const row of rows) {
addOption(row.key);
}
addOption(sessionKey);
return Array.from(groups.values());
}
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
@@ -487,88 +516,162 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
}
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = agentIdRaw.trim().toLowerCase();
const agent = (state.agentsList?.agents ?? []).find(
(entry) => entry.id.trim().toLowerCase() === normalized,
);
const name = agent?.identity?.name?.trim() || agent?.name?.trim() || "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
export function renderThemeToggle(state: AppViewState) {
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element };
if (event.clientX || event.clientY) {
context.pointerClientX = event.clientX;
context.pointerClientY = event.clientY;
function resolveSessionScopedOptionLabel(
key: string,
row?: SessionsListResult["sessions"][number],
rest?: string,
) {
const base = rest?.trim() || key;
if (!row) {
return base;
}
const displayName =
typeof row.displayName === "string" && row.displayName.trim().length > 0
? row.displayName.trim()
: null;
const label = typeof row.label === "string" ? row.label.trim() : "";
const showDisplayName = Boolean(
displayName && displayName !== key && displayName !== label && displayName !== base,
);
if (!showDisplayName) {
return base;
}
return `${base} · ${displayName}`;
}
type ThemeOption = { id: ThemeName; label: string; icon: string };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", icon: "🦀" },
{ id: "knot", label: "Knot", icon: "🪢" },
{ id: "dash", label: "Dash", icon: "📊" },
];
type ThemeModeOption = { id: ThemeMode; label: string; short: string };
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
{ id: "system", label: "System", short: "SYS" },
{ id: "light", label: "Light", short: "LIGHT" },
{ id: "dark", label: "Dark", short: "DARK" },
];
function currentThemeIcon(theme: ThemeName): string {
return THEME_OPTIONS.find((o) => o.id === theme)?.icon ?? "🎨";
}
export function renderTopbarThemeModeToggle(state: AppViewState) {
const modeIcon = (mode: ThemeMode) => {
if (mode === "system") {
return icons.monitor;
}
state.setTheme(next, context);
if (mode === "light") {
return icons.sun;
}
return icons.moon;
};
const applyMode = (mode: ThemeMode, e: Event) => {
if (mode === state.themeMode) {
return;
}
state.setThemeMode(mode, { element: e.currentTarget as HTMLElement });
};
return html`
<div class="theme-toggle" style="--theme-index: ${index};">
<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" : ""}"
@click=${applyTheme("system")}
aria-pressed=${state.theme === "system"}
aria-label="System theme"
title="System"
>
${renderMonitorIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
@click=${applyTheme("light")}
aria-pressed=${state.theme === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.theme === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
<div class="topbar-theme-mode" role="group" aria-label="Color mode">
${THEME_MODE_OPTIONS.map(
(opt) => html`
<button
type="button"
class="topbar-theme-mode__btn ${opt.id === state.themeMode ? "topbar-theme-mode__btn--active" : ""}"
title=${opt.label}
aria-label="Color mode: ${opt.label}"
aria-pressed=${opt.id === state.themeMode}
@click=${(e: Event) => applyMode(opt.id, e)}
>
${modeIcon(opt.id)}
</button>
`,
)}
</div>
`;
}
function renderSunIcon() {
return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
`;
}
export function renderThemeToggle(state: AppViewState) {
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
orb.classList.toggle("theme-orb--open", nextOpen);
const trigger = orb.querySelector<HTMLButtonElement>(".theme-orb__trigger");
const menu = orb.querySelector<HTMLElement>(".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`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
></path>
</svg>
`;
}
const toggleOpen = (e: Event) => {
const orb = (e.currentTarget as HTMLElement).closest<HTMLElement>(".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<HTMLElement>(".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`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
<line x1="8" x2="16" y1="21" y2="21"></line>
<line x1="12" x2="12" y1="17" y2="21"></line>
</svg>
<div class="theme-orb" aria-label="Theme">
<button
type="button"
class="theme-orb__trigger"
title="Theme"
aria-haspopup="menu"
aria-expanded="false"
@click=${toggleOpen}
>${currentThemeIcon(state.theme)}</button>
<div class="theme-orb__menu" role="menu" aria-hidden="true">
${THEME_OPTIONS.map(
(opt) => html`
<button
type="button"
class="theme-orb__option ${opt.id === state.theme ? "theme-orb__option--active" : ""}"
title=${opt.label}
role="menuitemradio"
aria-checked=${opt.id === state.theme}
aria-label=${opt.label}
@click=${(e: Event) => pick(opt, e)}
>${opt.icon}</button>`,
)}
</div>
</div>
`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
import { refreshChat } from "./app-chat.ts";
import {
startLogsPolling,
@@ -9,15 +10,10 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
import type { OpenClawApp } from "./app.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 { loadConfig, loadConfigSchema } from "./controllers/config.ts";
import {
loadCronJobs,
loadCronModelSuggestions,
loadCronRuns,
loadCronStatus,
} from "./controllers/cron.ts";
import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts";
import { loadDebug } from "./controllers/debug.ts";
import { loadDevices } from "./controllers/devices.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
@@ -26,6 +22,7 @@ import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { loadSkills } from "./controllers/skills.ts";
import { loadUsage } from "./controllers/usage.ts";
import {
inferBasePathFromPathname,
normalizeBasePath,
@@ -36,13 +33,16 @@ 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 type { AgentsListResult } from "./types.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
let systemThemeCleanup: (() => void) | null = null;
import type { AgentsListResult, AttentionItem } from "./types.ts";
type SettingsHost = {
settings: UiSettings;
password?: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
sessionKey: string;
@@ -56,8 +56,6 @@ type SettingsHost = {
agentsList?: AgentsListResult | null;
agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null;
};
@@ -68,9 +66,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;
}
@@ -152,18 +151,36 @@ 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 resolved = resolveTheme(next, host.themeMode);
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
applyResolvedTheme(host, resolveTheme(next));
};
startThemeTransition({
nextTheme: next,
nextTheme: resolved,
applyTheme,
context,
currentTheme: host.theme,
currentTheme: host.themeResolved,
});
syncSystemThemeListener(host);
}
export function setThemeMode(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
const resolved = resolveTheme(host.theme, next);
const applyMode = () => {
applySettings(host, { ...host.settings, themeMode: next });
};
startThemeTransition({
nextTheme: resolved,
applyTheme: applyMode,
context,
currentTheme: host.themeResolved,
});
syncSystemThemeListener(host);
}
export async function refreshActiveTab(host: SettingsHost) {
@@ -187,7 +204,6 @@ export async function refreshActiveTab(host: SettingsHost) {
}
if (host.tab === "agents") {
await loadAgents(host as unknown as OpenClawApp);
await loadToolsCatalog(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp);
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
@@ -221,7 +237,14 @@ export async function refreshActiveTab(host: SettingsHost) {
!host.chatHasAutoScrolled,
);
}
if (host.tab === "config") {
if (
host.tab === "config" ||
host.tab === "communications" ||
host.tab === "appearance" ||
host.tab === "automation" ||
host.tab === "infrastructure" ||
host.tab === "aiAgents"
) {
await loadConfigSchema(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp);
}
@@ -248,8 +271,21 @@ export function inferBasePath() {
}
export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme));
host.theme = host.settings.theme ?? "claw";
host.themeMode = host.settings.themeMode ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
syncSystemThemeListener(host);
}
export function attachThemeListener(host: SettingsHost) {
syncSystemThemeListener(host);
}
export function detachThemeListener(_host: SettingsHost) {
if (systemThemeCleanup) {
systemThemeCleanup();
systemThemeCleanup = null;
}
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@@ -259,44 +295,33 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
}
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = resolved;
root.style.colorScheme = resolved.endsWith("light") ? "light" : "dark";
}
export function attachThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
function syncSystemThemeListener(host: SettingsHost) {
if (host.themeMode !== "system") {
if (systemThemeCleanup) {
systemThemeCleanup();
systemThemeCleanup = null;
}
return;
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") {
if (systemThemeCleanup) {
return;
}
if (typeof globalThis.matchMedia !== "function") {
return;
}
const mql = globalThis.matchMedia("(prefers-color-scheme: light)");
const onChange = () => {
if (host.themeMode !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light");
applyResolvedTheme(host, resolveTheme(host.theme, "system"));
};
if (typeof host.themeMedia.addEventListener === "function") {
host.themeMedia.addEventListener("change", host.themeMediaHandler);
return;
}
const legacy = host.themeMedia as MediaQueryList & {
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.addListener(host.themeMediaHandler);
}
export function detachThemeListener(host: SettingsHost) {
if (!host.themeMedia || !host.themeMediaHandler) {
return;
}
if (typeof host.themeMedia.removeEventListener === "function") {
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
return;
}
const legacy = host.themeMedia as MediaQueryList & {
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.removeListener(host.themeMediaHandler);
host.themeMedia = null;
host.themeMediaHandler = null;
mql.addEventListener("change", onChange);
systemThemeCleanup = () => mql.removeEventListener("change", onChange);
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@@ -405,13 +430,143 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
}
export async function loadOverview(host: SettingsHost) {
await Promise.all([
loadChannels(host as unknown as OpenClawApp, false),
loadPresence(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp),
loadCronStatus(host as unknown as OpenClawApp),
loadDebug(host as unknown as OpenClawApp),
const app = host as unknown as OpenClawApp;
await Promise.allSettled([
loadChannels(app, false),
loadPresence(app),
loadSessions(app),
loadCronStatus(app),
loadCronJobs(app),
loadDebug(app),
loadSkills(app),
loadUsage(app),
loadOverviewLogs(app),
]);
buildAttentionItems(app);
}
export function hasOperatorReadAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return false;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.read"],
allowedScopes: auth.scopes,
});
}
export function hasMissingSkillDependencies(
missing: Record<string, unknown> | null | undefined,
): boolean {
if (!missing) {
return false;
}
return Object.values(missing).some((value) => Array.isArray(value) && value.length > 0);
}
async function loadOverviewLogs(host: OpenClawApp) {
if (!host.client || !host.connected) {
return;
}
try {
const res = await host.client.request("logs.tail", {
cursor: host.overviewLogCursor || undefined,
limit: 100,
maxBytes: 50_000,
});
const payload = res as {
cursor?: number;
lines?: unknown;
};
const lines = Array.isArray(payload.lines)
? payload.lines.filter((line): line is string => typeof line === "string")
: [];
host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500);
if (typeof payload.cursor === "number") {
host.overviewLogCursor = payload.cursor;
}
} catch {
/* non-critical */
}
}
function buildAttentionItems(host: OpenClawApp) {
const items: AttentionItem[] = [];
if (host.lastError) {
items.push({
severity: "error",
icon: "x",
title: "Gateway Error",
description: host.lastError,
});
}
const hello = host.hello;
const auth = (hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? null;
if (auth?.scopes && !hasOperatorReadAccess(auth)) {
items.push({
severity: "warning",
icon: "key",
title: "Missing operator.read scope",
description:
"This connection does not have the operator.read scope. Some features may be unavailable.",
href: "https://docs.openclaw.ai/web/dashboard",
external: true,
});
}
const skills = host.skillsReport?.skills ?? [];
const missingDeps = skills.filter((s) => !s.disabled && hasMissingSkillDependencies(s.missing));
if (missingDeps.length > 0) {
const names = missingDeps.slice(0, 3).map((s) => s.name);
const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : "";
items.push({
severity: "warning",
icon: "zap",
title: "Skills with missing dependencies",
description: `${names.join(", ")}${more}`,
});
}
const blocked = skills.filter((s) => s.blockedByAllowlist);
if (blocked.length > 0) {
items.push({
severity: "warning",
icon: "shield",
title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`,
description: blocked.map((s) => s.name).join(", "),
});
}
const cronJobs = host.cronJobs ?? [];
const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error");
if (failedCron.length > 0) {
items.push({
severity: "error",
icon: "clock",
title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`,
description: failedCron.map((j) => j.name).join(", "),
});
}
const now = Date.now();
const overdue = cronJobs.filter(
(j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000,
);
if (overdue.length > 0) {
items.push({
severity: "warning",
icon: "clock",
title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`,
description: overdue.map((j) => j.name).join(", "),
});
}
host.attentionItems = items;
}
export async function loadChannelsTab(host: SettingsHost) {
@@ -423,18 +578,9 @@ export async function loadChannelsTab(host: SettingsHost) {
}
export async function loadCron(host: SettingsHost) {
const cronHost = host as unknown as OpenClawApp;
await Promise.all([
loadChannels(host as unknown as OpenClawApp, false),
loadCronStatus(cronHost),
loadCronJobs(cronHost),
loadCronModelSuggestions(cronHost),
loadCronStatus(host as unknown as OpenClawApp),
loadCronJobs(host as unknown as OpenClawApp),
]);
if (cronHost.cronRunsScope === "all") {
await loadCronRuns(cronHost, null);
return;
}
if (cronHost.cronRunsJobId) {
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
}
}

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,7 +29,6 @@ import type {
SessionUsageTimeSeries,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
@@ -37,15 +38,18 @@ 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;
eventLog: EventLogEntry[];
assistantName: string;
assistantAvatar: string | null;
@@ -109,6 +113,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;
@@ -128,9 +152,6 @@ export type AppViewState = {
agentsList: AgentsListResult | null;
agentsError: string | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
agentFilesLoading: boolean;
agentFilesError: string | null;
@@ -154,6 +175,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;
@@ -232,10 +259,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;
@@ -255,11 +285,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 {
@@ -53,7 +54,6 @@ import {
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { CronFieldErrors } from "./controllers/cron.ts";
import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -61,7 +61,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 { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@@ -71,19 +71,18 @@ import type {
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
HealthSummary,
LogEntry,
LogLevel,
ModelCatalogEntry,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
NostrProfile,
} from "./types.ts";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
declare global {
@@ -94,6 +93,28 @@ declare global {
const bootAssistantIdentity = normalizeAssistantIdentity({});
function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = typeof m.content === "string" ? m.content : "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
const blob = new Blob([lines.join("\n")], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `chat-${assistantName}-${Date.now()}.md`;
a.click();
URL.revokeObjectURL(url);
}
function resolveOnboardingMode(): boolean {
if (!window.location.search) {
return false;
@@ -120,14 +141,17 @@ export class OpenClawApp extends LitElement {
}
}
@state() password = "";
@state() loginShowGatewayToken = false;
@state() loginShowGatewayPassword = false;
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() theme: ThemeName = this.settings.theme ?? "claw";
@state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
@state() themeResolved: ResolvedTheme = "dark";
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() lastErrorCode: string | null = null;
@state() eventLog: EventLogEntry[] = [];
private eventLogBuffer: EventLogEntry[] = [];
private toolStreamSyncTimer: number | null = null;
@@ -154,6 +178,9 @@ export class OpenClawApp extends LitElement {
@state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = [];
@state() chatManualRefreshInFlight = false;
onSlashAction?: (action: string) => void;
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@@ -199,6 +226,26 @@ export class OpenClawApp extends LitElement {
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null;
@state() communicationsFormMode: "form" | "raw" = "form";
@state() communicationsSearchQuery = "";
@state() communicationsActiveSection: string | null = null;
@state() communicationsActiveSubsection: string | null = null;
@state() appearanceFormMode: "form" | "raw" = "form";
@state() appearanceSearchQuery = "";
@state() appearanceActiveSection: string | null = null;
@state() appearanceActiveSubsection: string | null = null;
@state() automationFormMode: "form" | "raw" = "form";
@state() automationSearchQuery = "";
@state() automationActiveSection: string | null = null;
@state() automationActiveSubsection: string | null = null;
@state() infrastructureFormMode: "form" | "raw" = "form";
@state() infrastructureSearchQuery = "";
@state() infrastructureActiveSection: string | null = null;
@state() infrastructureActiveSubsection: string | null = null;
@state() aiAgentsFormMode: "form" | "raw" = "form";
@state() aiAgentsSearchQuery = "";
@state() aiAgentsActiveSection: string | null = null;
@state() aiAgentsActiveSubsection: string | null = null;
@state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
@@ -220,9 +267,6 @@ export class OpenClawApp extends LitElement {
@state() agentsList: AgentsListResult | null = null;
@state() agentsError: string | null = null;
@state() agentsSelectedId: string | null = null;
@state() toolsCatalogLoading = false;
@state() toolsCatalogError: string | null = null;
@state() toolsCatalogResult: ToolsCatalogResult | null = null;
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" =
"overview";
@state() agentFilesLoading = false;
@@ -248,6 +292,12 @@ export class OpenClawApp extends LitElement {
@state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false;
@state() sessionsHideCron = true;
@state() sessionsSearchQuery = "";
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
@state() sessionsSortDir: "asc" | "desc" = "desc";
@state() sessionsPage = 0;
@state() sessionsPageSize = 10;
@state() sessionsActionsOpenKey: string | null = null;
@state() usageLoading = false;
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
@@ -322,7 +372,7 @@ export class OpenClawApp extends LitElement {
@state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronFieldErrors: CronFieldErrors = {};
@state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {};
@state() cronEditingJobId: string | null = null;
@state() cronRunsJobId: string | null = null;
@state() cronRunsLoadingMore = false;
@@ -342,6 +392,25 @@ export class OpenClawApp extends LitElement {
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
// Overview dashboard state
@state() attentionItems: import("./types.js").AttentionItem[] = [];
@state() paletteOpen = false;
@state() paletteQuery = "";
@state() paletteActiveIndex = 0;
@state() streamMode = (() => {
try {
const stored = localStorage.getItem("openclaw:stream-mode");
// Default to true (redacted) unless explicitly disabled
return stored === null ? true : stored === "true";
} catch {
return true;
}
})();
@state() overviewShowGatewayToken = false;
@state() overviewShowGatewayPassword = false;
@state() overviewLogLines: string[] = [];
@state() overviewLogCursor = 0;
@state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@@ -350,10 +419,14 @@ export class OpenClawApp extends LitElement {
@state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {};
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;
@state() healthError: string | null = null;
@state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSnapshot | null = null;
@state() debugModels: unknown[] = [];
@state() debugHealth: HealthSummary | null = null;
@state() debugModels: ModelCatalogEntry[] = [];
@state() debugHeartbeat: unknown = null;
@state() debugCallMethod = "";
@state() debugCallParams = "{}";
@@ -392,9 +465,17 @@ export class OpenClawApp extends LitElement {
basePath = "";
private popStateHandler = () =>
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
private globalKeydownHandler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "k") {
e.preventDefault();
this.paletteOpen = !this.paletteOpen;
if (this.paletteOpen) {
this.paletteQuery = "";
this.paletteActiveIndex = 0;
}
}
};
createRenderRoot() {
return this;
@@ -402,6 +483,20 @@ export class OpenClawApp extends LitElement {
connectedCallback() {
super.connectedCallback();
this.onSlashAction = (action: string) => {
switch (action) {
case "toggle-focus":
this.applySettings({
...this.settings,
chatFocusMode: !this.settings.chatFocusMode,
});
break;
case "export":
exportChatMarkdown(this.chatMessages, this.assistantName);
break;
}
};
document.addEventListener("keydown", this.globalKeydownHandler);
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
}
@@ -410,6 +505,7 @@ export class OpenClawApp extends LitElement {
}
disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler);
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
@@ -469,8 +565,23 @@ 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);
this.themeOrder = this.buildThemeOrder(next);
}
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
setThemeModeInternal(
this as unknown as Parameters<typeof setThemeModeInternal>[0],
next,
context,
);
}
buildThemeOrder(active: ThemeName): ThemeName[] {
const all = [...VALID_THEME_NAMES];
const rest = all.filter((id) => id !== active);
return [active, ...rest];
}
async loadOverview() {

View File

@@ -0,0 +1,49 @@
const PREFIX = "openclaw:deleted:";
export class DeletedMessages {
private key: string;
private _keys = new Set<string>();
constructor(sessionKey: string) {
this.key = PREFIX + sessionKey;
this.load();
}
has(key: string): boolean {
return this._keys.has(key);
}
delete(key: string): void {
this._keys.add(key);
this.save();
}
restore(key: string): void {
this._keys.delete(key);
this.save();
}
clear(): void {
this._keys.clear();
this.save();
}
private load(): void {
try {
const raw = localStorage.getItem(this.key);
if (!raw) {
return;
}
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
this._keys = new Set(arr.filter((s) => typeof s === "string"));
}
} catch {
// ignore
}
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
}
}

View File

@@ -1,10 +1,12 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { openExternalUrlSafe } from "../open-external-url.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { MessageGroup } from "../types/chat-types.ts";
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import { agentLogoUrl } from "../views/agents-utils.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
extractTextCached,
@@ -12,6 +14,7 @@ import {
formatReasoningMarkdown,
} from "./message-extract.ts";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
type ImageBlock = {
@@ -56,10 +59,10 @@ function extractImages(message: unknown): ImageBlock[] {
return images;
}
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant)}
${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -76,6 +79,7 @@ export function renderStreamingGroup(
startedAt: number,
onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity,
basePath?: string,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
@@ -85,7 +89,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant)}
${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -112,6 +116,9 @@ export function renderMessageGroup(
showReasoning: boolean;
assistantName?: string;
assistantAvatar?: string | null;
basePath?: string;
contextWindow?: number | null;
onDelete?: () => void;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
@@ -121,20 +128,35 @@ export function renderMessageGroup(
? "You"
: normalizedRole === "assistant"
? assistantName
: normalizedRole;
: normalizedRole === "tool"
? "Tool"
: normalizedRole;
const roleClass =
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other";
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: normalizedRole === "tool"
? "tool"
: "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
// Aggregate usage/cost/model across all messages in the group
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
return html`
<div class="chat-group ${roleClass}">
${renderAvatar(group.role, {
name: assistantName,
avatar: opts.assistantAvatar ?? null,
})}
${renderAvatar(
group.role,
{
name: assistantName,
avatar: opts.assistantAvatar ?? null,
},
opts.basePath,
)}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
renderGroupedMessage(
@@ -149,24 +171,240 @@ export function renderMessageGroup(
<div class="chat-group-footer">
<span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span>
${renderMessageMeta(meta)}
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
${
opts.onDelete
? html`<button
class="chat-group-delete"
@click=${opts.onDelete}
title="Delete"
aria-label="Delete message"
>${icons.x}</button>`
: nothing
}
</div>
</div>
</div>
`;
}
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
// ── Per-message metadata (tokens, cost, model, context %) ──
type GroupMeta = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
model: string | null;
contextPercent: number | null;
};
function extractGroupMeta(group: MessageGroup, contextWindow: number | null): GroupMeta | null {
let input = 0;
let output = 0;
let cacheRead = 0;
let cacheWrite = 0;
let cost = 0;
let model: string | null = null;
let hasUsage = false;
for (const { message } of group.messages) {
const m = message as Record<string, unknown>;
if (m.role !== "assistant") {
continue;
}
const usage = m.usage as Record<string, number> | undefined;
if (usage) {
hasUsage = true;
input += usage.input ?? usage.inputTokens ?? 0;
output += usage.output ?? usage.outputTokens ?? 0;
cacheRead += usage.cacheRead ?? usage.cache_read_input_tokens ?? 0;
cacheWrite += usage.cacheWrite ?? usage.cache_creation_input_tokens ?? 0;
}
const c = m.cost as Record<string, number> | undefined;
if (c?.total) {
cost += c.total;
}
if (typeof m.model === "string" && m.model !== "gateway-injected") {
model = m.model;
}
}
if (!hasUsage && !model) {
return null;
}
const contextPercent =
contextWindow && input > 0 ? Math.min(Math.round((input / contextWindow) * 100), 100) : null;
return { input, output, cacheRead, cacheWrite, cost, model, contextPercent };
}
/** Compact token count formatter (e.g. 128000 → "128k"). */
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}
function renderMessageMeta(meta: GroupMeta | null) {
if (!meta) {
return nothing;
}
const parts: Array<ReturnType<typeof html>> = [];
// Token counts: ↑input ↓output
if (meta.input) {
parts.push(html`<span class="msg-meta__tokens">↑${fmtTokens(meta.input)}</span>`);
}
if (meta.output) {
parts.push(html`<span class="msg-meta__tokens">↓${fmtTokens(meta.output)}</span>`);
}
// Cache: R/W
if (meta.cacheRead) {
parts.push(html`<span class="msg-meta__cache">R${fmtTokens(meta.cacheRead)}</span>`);
}
if (meta.cacheWrite) {
parts.push(html`<span class="msg-meta__cache">W${fmtTokens(meta.cacheWrite)}</span>`);
}
// Cost
if (meta.cost > 0) {
parts.push(html`<span class="msg-meta__cost">$${meta.cost.toFixed(4)}</span>`);
}
// Context %
if (meta.contextPercent !== null) {
const pct = meta.contextPercent;
const cls =
pct >= 90
? "msg-meta__ctx msg-meta__ctx--danger"
: pct >= 75
? "msg-meta__ctx msg-meta__ctx--warn"
: "msg-meta__ctx";
parts.push(html`<span class="${cls}">${pct}% ctx</span>`);
}
// Model
if (meta.model) {
// Shorten model name: strip provider prefix if present (e.g. "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet")
const shortModel = meta.model.includes("/") ? meta.model.split("/").pop()! : meta.model;
parts.push(html`<span class="msg-meta__model">${shortModel}</span>`);
}
if (parts.length === 0) {
return nothing;
}
return html`<span class="msg-meta">${parts}</span>`;
}
function extractGroupText(group: MessageGroup): string {
const parts: string[] = [];
for (const { message } of group.messages) {
const text = extractTextCached(message);
if (text?.trim()) {
parts.push(text.trim());
}
}
return parts.join("\n\n");
}
function renderTtsButton(group: MessageGroup) {
return html`
<button
class="chat-tts-btn"
type="button"
title=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
aria-label=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLButtonElement;
if (isTtsSpeaking()) {
stopTts();
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
return;
}
const text = extractGroupText(group);
if (!text) {
return;
}
btn.classList.add("chat-tts-btn--active");
btn.title = "Stop speaking";
speakText(text, {
onEnd: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
}
},
onError: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
}
},
});
}}
>
${icons.volume2}
</button>
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
basePath?: string,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
const initial =
normalized === "user"
? "U"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`
: normalized === "assistant"
? assistantName.charAt(0).toUpperCase() || "A"
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
</svg>
`
: normalized === "tool"
? "⚙"
: "?";
? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
/>
</svg>
`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="12" r="10" />
<text
x="12"
y="16.5"
text-anchor="middle"
font-size="14"
font-weight="600"
fill="var(--bg, #fff)"
>
?
</text>
</svg>
`;
const className =
normalized === "user"
? "user"
@@ -184,7 +422,21 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
alt="${assistantName}"
/>`;
}
/* Assistant with no custom avatar: use logo when basePath available */
if (normalized === "assistant" && basePath) {
const logoUrl = agentLogoUrl(basePath);
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${logoUrl}"
alt="${assistantName}"
/>`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
@@ -221,6 +473,66 @@ function renderMessageImages(images: ImageBlock[]) {
`;
}
/** Render tool cards inside a collapsed `<details>` element. */
function renderCollapsedToolCards(
toolCards: ToolCard[],
onOpenSidebar?: (content: string) => void,
) {
const calls = toolCards.filter((c) => c.kind === "call");
const results = toolCards.filter((c) => c.kind === "result");
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const summaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
return html`
<details class="chat-tools-collapse">
<summary class="chat-tools-summary">
<span class="chat-tools-summary__icon">${icons.zap}</span>
<span class="chat-tools-summary__count">${totalTools} tool${totalTools === 1 ? "" : "s"}</span>
<span class="chat-tools-summary__names">${summaryLabel}</span>
</summary>
<div class="chat-tools-collapse__body">
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
</details>
`;
}
/**
* Detect whether a trimmed string is a JSON object or array.
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
*/
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
const t = text.trim();
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
const parsed = JSON.parse(t);
return { parsed, pretty: JSON.stringify(parsed, null, 2) };
} catch {
return null;
}
}
return null;
}
/** Build a short summary label for collapsed JSON (type + key count or array length). */
function jsonSummaryLabel(parsed: unknown): string {
if (Array.isArray(parsed)) {
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`;
}
if (parsed && typeof parsed === "object") {
const keys = Object.keys(parsed as Record<string, unknown>);
if (keys.length <= 4) {
return `{ ${keys.join(", ")} }`;
}
return `Object (${keys.length} keys)`;
}
return "JSON";
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
@@ -228,6 +540,7 @@ function renderGroupedMessage(
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolResult =
isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" ||
@@ -248,40 +561,99 @@ function renderGroupedMessage(
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
.filter(Boolean)
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
return renderCollapsedToolCards(toolCards, onOpenSidebar);
}
if (!markdown && !hasToolCards && !hasImages) {
return nothing;
}
const isToolMessage = normalizedRole === "tool" || isToolResult;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const toolSummaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolPreview =
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : "";
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${canCopyMarkdown ? html`<div class="chat-bubble-actions">${renderCopyAsMarkdownButton(markdown!)}</div>` : nothing}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
isToolMessage
? html`
<details class="chat-tool-msg-collapse">
<summary class="chat-tool-msg-summary">
<span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">Tool output</span>
${
toolSummaryLabel
? html`<span class="chat-tool-msg-summary__names">${toolSummaryLabel}</span>`
: toolPreview
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
: nothing
}
</summary>
<div class="chat-tool-msg-body">
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
</div>
</details>
`
: html`
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
`
}
${
markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}

View File

@@ -0,0 +1,49 @@
const MAX = 50;
export class InputHistory {
private items: string[] = [];
private cursor = -1;
push(text: string): void {
const trimmed = text.trim();
if (!trimmed) {
return;
}
if (this.items[this.items.length - 1] === trimmed) {
return;
}
this.items.push(trimmed);
if (this.items.length > MAX) {
this.items.shift();
}
this.cursor = -1;
}
up(): string | null {
if (this.items.length === 0) {
return null;
}
if (this.cursor < 0) {
this.cursor = this.items.length - 1;
} else if (this.cursor > 0) {
this.cursor--;
}
return this.items[this.cursor] ?? null;
}
down(): string | null {
if (this.cursor < 0) {
return null;
}
this.cursor++;
if (this.cursor >= this.items.length) {
this.cursor = -1;
return null;
}
return this.items[this.cursor] ?? null;
}
reset(): void {
this.cursor = -1;
}
}

View File

@@ -0,0 +1,61 @@
const PREFIX = "openclaw:pinned:";
export class PinnedMessages {
private key: string;
private _indices = new Set<number>();
constructor(sessionKey: string) {
this.key = PREFIX + sessionKey;
this.load();
}
get indices(): Set<number> {
return this._indices;
}
has(index: number): boolean {
return this._indices.has(index);
}
pin(index: number): void {
this._indices.add(index);
this.save();
}
unpin(index: number): void {
this._indices.delete(index);
this.save();
}
toggle(index: number): void {
if (this._indices.has(index)) {
this.unpin(index);
} else {
this.pin(index);
}
}
clear(): void {
this._indices.clear();
this.save();
}
private load(): void {
try {
const raw = localStorage.getItem(this.key);
if (!raw) {
return;
}
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
this._indices = new Set(arr.filter((n) => typeof n === "number"));
}
} catch {
// ignore
}
}
private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
}
}

View File

@@ -0,0 +1,302 @@
/**
* Client-side execution engine for slash commands.
* Calls gateway RPC methods and returns formatted results.
*/
import type { GatewayBrowserClient } from "../gateway.ts";
import type {
AgentsListResult,
GatewaySessionRow,
HealthSummary,
ModelCatalogEntry,
SessionsListResult,
} from "../types.ts";
import { SLASH_COMMANDS } from "./slash-commands.ts";
export type SlashCommandResult = {
/** Markdown-formatted result to display in chat. */
content: string;
/** Side-effect action the caller should perform after displaying the result. */
action?:
| "refresh"
| "export"
| "new-session"
| "reset"
| "stop"
| "clear"
| "toggle-focus"
| "navigate-usage";
};
export async function executeSlashCommand(
client: GatewayBrowserClient,
sessionKey: string,
commandName: string,
args: string,
): Promise<SlashCommandResult> {
switch (commandName) {
case "help":
return executeHelp();
case "status":
return await executeStatus(client);
case "new":
return { content: "Starting new session...", action: "new-session" };
case "reset":
return { content: "Resetting session...", action: "reset" };
case "stop":
return { content: "Stopping current run...", action: "stop" };
case "clear":
return { content: "Chat history cleared.", action: "clear" };
case "focus":
return { content: "Toggled focus mode.", action: "toggle-focus" };
case "compact":
return await executeCompact(client, sessionKey);
case "model":
return await executeModel(client, sessionKey, args);
case "think":
return await executeThink(client, sessionKey, args);
case "verbose":
return await executeVerbose(client, sessionKey, args);
case "export":
return { content: "Exporting session...", action: "export" };
case "usage":
return await executeUsage(client, sessionKey);
case "agents":
return await executeAgents(client);
case "kill":
return await executeKill(client, args);
default:
return { content: `Unknown command: \`/${commandName}\`` };
}
}
// ── Command Implementations ──
function executeHelp(): SlashCommandResult {
const lines = ["**Available Commands**\n"];
let currentCategory = "";
for (const cmd of SLASH_COMMANDS) {
const cat = cmd.category ?? "session";
if (cat !== currentCategory) {
currentCategory = cat;
lines.push(`**${cat.charAt(0).toUpperCase() + cat.slice(1)}**`);
}
const argStr = cmd.args ? ` ${cmd.args}` : "";
const local = cmd.executeLocal ? "" : " *(agent)*";
lines.push(`\`/${cmd.name}${argStr}\`${cmd.description}${local}`);
}
lines.push("\nType `/` to open the command menu.");
return { content: lines.join("\n") };
}
async function executeStatus(client: GatewayBrowserClient): Promise<SlashCommandResult> {
try {
const health = await client.request<HealthSummary>("health", {});
const status = health.ok ? "Healthy" : "Degraded";
const agentCount = health.agents?.length ?? 0;
const sessionCount = health.sessions?.count ?? 0;
const lines = [
`**System Status:** ${status}`,
`**Agents:** ${agentCount}`,
`**Sessions:** ${sessionCount}`,
`**Default Agent:** ${health.defaultAgentId || "none"}`,
];
if (health.durationMs) {
lines.push(`**Response:** ${health.durationMs}ms`);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to fetch status: ${String(err)}` };
}
}
async function executeCompact(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<SlashCommandResult> {
try {
await client.request("sessions.compact", { key: sessionKey });
return { content: "Context compacted successfully.", action: "refresh" };
} catch (err) {
return { content: `Compaction failed: ${String(err)}` };
}
}
async function executeModel(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
if (!args) {
try {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey);
const model = session?.model || sessions?.defaults?.model || "default";
const models = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
const available = models?.models?.map((m: ModelCatalogEntry) => m.id) ?? [];
const lines = [`**Current model:** \`${model}\``];
if (available.length > 0) {
lines.push(
`**Available:** ${available
.slice(0, 10)
.map((m: string) => `\`${m}\``)
.join(", ")}${available.length > 10 ? ` +${available.length - 10} more` : ""}`,
);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to get model info: ${String(err)}` };
}
}
try {
await client.request("sessions.patch", { key: sessionKey, model: args.trim() });
return { content: `Model set to \`${args.trim()}\`.`, action: "refresh" };
} catch (err) {
return { content: `Failed to set model: ${String(err)}` };
}
}
async function executeThink(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const valid = ["off", "low", "medium", "high"];
const level = args.trim().toLowerCase();
if (!level) {
return {
content: `Usage: \`/think <${valid.join("|")}>\``,
};
}
if (!valid.includes(level)) {
return {
content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`,
};
}
try {
await client.request("sessions.patch", { key: sessionKey, thinkingLevel: level });
return {
content: `Thinking level set to **${level}**.`,
action: "refresh",
};
} catch (err) {
return { content: `Failed to set thinking level: ${String(err)}` };
}
}
async function executeVerbose(
client: GatewayBrowserClient,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const valid = ["on", "off", "full"];
const level = args.trim().toLowerCase();
if (!level) {
return {
content: `Usage: \`/verbose <${valid.join("|")}>\``,
};
}
if (!valid.includes(level)) {
return {
content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`,
};
}
try {
await client.request("sessions.patch", { key: sessionKey, verboseLevel: level });
return {
content: `Verbose mode set to **${level}**.`,
action: "refresh",
};
} catch (err) {
return { content: `Failed to set verbose mode: ${String(err)}` };
}
}
async function executeUsage(
client: GatewayBrowserClient,
sessionKey: string,
): Promise<SlashCommandResult> {
try {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const session = sessions?.sessions?.find((s: GatewaySessionRow) => s.key === sessionKey);
if (!session) {
return { content: "No active session." };
}
const input = session.inputTokens ?? 0;
const output = session.outputTokens ?? 0;
const total = session.totalTokens ?? input + output;
const ctx = session.contextTokens ?? 0;
const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null;
const lines = [
"**Session Usage**",
`Input: **${fmtTokens(input)}** tokens`,
`Output: **${fmtTokens(output)}** tokens`,
`Total: **${fmtTokens(total)}** tokens`,
];
if (pct !== null) {
lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`);
}
if (session.model) {
lines.push(`Model: \`${session.model}\``);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to get usage: ${String(err)}` };
}
}
async function executeAgents(client: GatewayBrowserClient): Promise<SlashCommandResult> {
try {
const result = await client.request<AgentsListResult>("agents.list", {});
const agents = result?.agents ?? [];
if (agents.length === 0) {
return { content: "No agents configured." };
}
const lines = [`**Agents** (${agents.length})\n`];
for (const agent of agents) {
const isDefault = agent.id === result?.defaultId;
const name = agent.identity?.name || agent.name || agent.id;
const marker = isDefault ? " *(default)*" : "";
lines.push(`- \`${agent.id}\`${name}${marker}`);
}
return { content: lines.join("\n") };
} catch (err) {
return { content: `Failed to list agents: ${String(err)}` };
}
}
async function executeKill(
client: GatewayBrowserClient,
args: string,
): Promise<SlashCommandResult> {
const target = args.trim();
if (!target) {
return { content: "Usage: `/kill <id|all>`" };
}
try {
await client.request("chat.abort", target === "all" ? {} : { agentId: target });
return {
content: target === "all" ? "All agents aborted." : `Agent \`${target}\` aborted.`,
};
} catch (err) {
return { content: `Failed to abort: ${String(err)}` };
}
}
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}

View File

@@ -0,0 +1,217 @@
import type { IconName } from "../icons.ts";
export type SlashCommandCategory = "session" | "model" | "agents" | "tools";
export type SlashCommandDef = {
name: string;
description: string;
args?: string;
icon?: IconName;
category?: SlashCommandCategory;
/** When true, the command is executed client-side via RPC instead of sent to the agent. */
executeLocal?: boolean;
/** Fixed argument choices for inline hints. */
argOptions?: string[];
/** Keyboard shortcut hint shown in the menu (display only). */
shortcut?: string;
};
export const SLASH_COMMANDS: SlashCommandDef[] = [
// ── Session ──
{
name: "new",
description: "Start a new session",
icon: "plus",
category: "session",
executeLocal: true,
},
{
name: "reset",
description: "Reset current session",
icon: "refresh",
category: "session",
executeLocal: true,
},
{
name: "compact",
description: "Compact session context",
icon: "loader",
category: "session",
executeLocal: true,
},
{
name: "stop",
description: "Stop current run",
icon: "stop",
category: "session",
executeLocal: true,
},
{
name: "clear",
description: "Clear chat history",
icon: "trash",
category: "session",
executeLocal: true,
},
{
name: "focus",
description: "Toggle focus mode",
icon: "eye",
category: "session",
executeLocal: true,
},
// ── Model ──
{
name: "model",
description: "Show or set model",
args: "<name>",
icon: "brain",
category: "model",
executeLocal: true,
},
{
name: "think",
description: "Set thinking level",
args: "<level>",
icon: "brain",
category: "model",
executeLocal: true,
argOptions: ["off", "low", "medium", "high"],
},
{
name: "verbose",
description: "Toggle verbose mode",
args: "<on|off|full>",
icon: "terminal",
category: "model",
executeLocal: true,
argOptions: ["on", "off", "full"],
},
// ── Tools ──
{
name: "help",
description: "Show available commands",
icon: "book",
category: "tools",
executeLocal: true,
},
{
name: "status",
description: "Show system status",
icon: "barChart",
category: "tools",
executeLocal: true,
},
{
name: "export",
description: "Export session to Markdown",
icon: "download",
category: "tools",
executeLocal: true,
},
{
name: "usage",
description: "Show token usage",
icon: "barChart",
category: "tools",
executeLocal: true,
},
// ── Agents ──
{
name: "agents",
description: "List agents",
icon: "monitor",
category: "agents",
executeLocal: true,
},
{
name: "kill",
description: "Abort sub-agents",
args: "<id|all>",
icon: "x",
category: "agents",
executeLocal: true,
},
{
name: "skill",
description: "Run a skill",
args: "<name>",
icon: "zap",
category: "tools",
},
{
name: "steer",
description: "Steer a sub-agent",
args: "<id> <msg>",
icon: "send",
category: "agents",
},
];
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "tools", "agents"];
export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
session: "Session",
model: "Model",
agents: "Agents",
tools: "Tools",
};
export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
const lower = filter.toLowerCase();
const commands = lower
? SLASH_COMMANDS.filter(
(cmd) => cmd.name.startsWith(lower) || cmd.description.toLowerCase().includes(lower),
)
: SLASH_COMMANDS;
return commands.toSorted((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a.category ?? "session");
const bi = CATEGORY_ORDER.indexOf(b.category ?? "session");
if (ai !== bi) {
return ai - bi;
}
// Exact prefix matches first
if (lower) {
const aExact = a.name.startsWith(lower) ? 0 : 1;
const bExact = b.name.startsWith(lower) ? 0 : 1;
if (aExact !== bExact) {
return aExact - bExact;
}
}
return 0;
});
}
export type ParsedSlashCommand = {
command: SlashCommandDef;
args: string;
};
/**
* Parse a message as a slash command. Returns null if it doesn't match.
* Supports `/command` and `/command args...`.
*/
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/")) {
return null;
}
const spaceIdx = trimmed.indexOf(" ");
const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx);
const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
if (!name) {
return null;
}
const command = SLASH_COMMANDS.find((cmd) => cmd.name === name.toLowerCase());
if (!command) {
return null;
}
return { command, args };
}

225
ui/src/ui/chat/speech.ts Normal file
View File

@@ -0,0 +1,225 @@
/**
* Browser-native speech services: STT via SpeechRecognition, TTS via SpeechSynthesis.
* Falls back gracefully when APIs are unavailable.
*/
// ─── STT (Speech-to-Text) ───
type SpeechRecognitionEvent = Event & {
results: SpeechRecognitionResultList;
resultIndex: number;
};
type SpeechRecognitionErrorEvent = Event & {
error: string;
message?: string;
};
interface SpeechRecognitionInstance extends EventTarget {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
abort(): void;
onresult: ((event: SpeechRecognitionEvent) => void) | null;
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
onend: (() => void) | null;
onstart: (() => void) | null;
}
type SpeechRecognitionCtor = new () => SpeechRecognitionInstance;
function getSpeechRecognitionCtor(): SpeechRecognitionCtor | null {
const w = globalThis as Record<string, unknown>;
return (w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null) as SpeechRecognitionCtor | null;
}
export function isSttSupported(): boolean {
return getSpeechRecognitionCtor() !== null;
}
export type SttCallbacks = {
onTranscript: (text: string, isFinal: boolean) => void;
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
};
let activeRecognition: SpeechRecognitionInstance | null = null;
export function startStt(callbacks: SttCallbacks): boolean {
const Ctor = getSpeechRecognitionCtor();
if (!Ctor) {
callbacks.onError?.("Speech recognition is not supported in this browser");
return false;
}
stopStt();
const recognition = new Ctor();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = navigator.language || "en-US";
recognition.addEventListener("start", () => callbacks.onStart?.());
recognition.addEventListener("result", (event) => {
const speechEvent = event as unknown as SpeechRecognitionEvent;
let interimTranscript = "";
let finalTranscript = "";
for (let i = speechEvent.resultIndex; i < speechEvent.results.length; i++) {
const result = speechEvent.results[i];
if (!result?.[0]) {
continue;
}
const transcript = result[0].transcript;
if (result.isFinal) {
finalTranscript += transcript;
} else {
interimTranscript += transcript;
}
}
if (finalTranscript) {
callbacks.onTranscript(finalTranscript, true);
} else if (interimTranscript) {
callbacks.onTranscript(interimTranscript, false);
}
});
recognition.addEventListener("error", (event) => {
const speechEvent = event as unknown as SpeechRecognitionErrorEvent;
if (speechEvent.error === "aborted" || speechEvent.error === "no-speech") {
return;
}
callbacks.onError?.(speechEvent.error);
});
recognition.addEventListener("end", () => {
if (activeRecognition === recognition) {
activeRecognition = null;
}
callbacks.onEnd?.();
});
activeRecognition = recognition;
recognition.start();
return true;
}
export function stopStt(): void {
if (activeRecognition) {
const r = activeRecognition;
activeRecognition = null;
try {
r.stop();
} catch {
// already stopped
}
}
}
export function isSttActive(): boolean {
return activeRecognition !== null;
}
// ─── TTS (Text-to-Speech) ───
export function isTtsSupported(): boolean {
return "speechSynthesis" in globalThis;
}
let currentUtterance: SpeechSynthesisUtterance | null = null;
export function speakText(
text: string,
opts?: {
onStart?: () => void;
onEnd?: () => void;
onError?: (error: string) => void;
},
): boolean {
if (!isTtsSupported()) {
opts?.onError?.("Speech synthesis is not supported in this browser");
return false;
}
stopTts();
const cleaned = stripMarkdown(text);
if (!cleaned.trim()) {
return false;
}
const utterance = new SpeechSynthesisUtterance(cleaned);
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.addEventListener("start", () => opts?.onStart?.());
utterance.addEventListener("end", () => {
if (currentUtterance === utterance) {
currentUtterance = null;
}
opts?.onEnd?.();
});
utterance.addEventListener("error", (e) => {
if (currentUtterance === utterance) {
currentUtterance = null;
}
if (e.error === "canceled" || e.error === "interrupted") {
return;
}
opts?.onError?.(e.error);
});
currentUtterance = utterance;
speechSynthesis.speak(utterance);
return true;
}
export function stopTts(): void {
if (currentUtterance) {
currentUtterance = null;
}
if (isTtsSupported()) {
speechSynthesis.cancel();
}
}
export function isTtsSpeaking(): boolean {
return isTtsSupported() && speechSynthesis.speaking;
}
/** Strip common markdown syntax for cleaner speech output. */
function stripMarkdown(text: string): string {
return (
text
// code blocks
.replace(/```[\s\S]*?```/g, "")
// inline code
.replace(/`[^`]+`/g, "")
// images
.replace(/!\[.*?\]\(.*?\)/g, "")
// links → keep text
.replace(/\[([^\]]+)\]\(.*?\)/g, "$1")
// headings
.replace(/^#{1,6}\s+/gm, "")
// bold/italic
.replace(/\*{1,3}(.*?)\*{1,3}/g, "$1")
.replace(/_{1,3}(.*?)_{1,3}/g, "$1")
// blockquotes
.replace(/^>\s?/gm, "")
// horizontal rules
.replace(/^[-*_]{3,}\s*$/gm, "")
// list markers
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
// HTML tags
.replace(/<[^>]+>/g, "")
// collapse whitespace
.replace(/\n{3,}/g, "\n\n")
.trim()
);
}

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

@@ -1,5 +1,5 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { AgentsListResult, ToolsCatalogResult } from "../types.ts";
import type { AgentsListResult } from "../types.ts";
export type AgentsState = {
client: GatewayBrowserClient | null;
@@ -8,9 +8,6 @@ export type AgentsState = {
agentsError: string | null;
agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
};
export async function loadAgents(state: AgentsState) {
@@ -38,27 +35,3 @@ export async function loadAgents(state: AgentsState) {
state.agentsLoading = false;
}
}
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) {
if (!state.client || !state.connected) {
return;
}
if (state.toolsCatalogLoading) {
return;
}
state.toolsCatalogLoading = true;
state.toolsCatalogError = null;
try {
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
agentId: agentId ?? state.agentsSelectedId ?? undefined,
includePlugins: true,
});
if (res) {
state.toolsCatalogResult = res;
}
} catch (err) {
state.toolsCatalogError = String(err);
} finally {
state.toolsCatalogLoading = false;
}
}

View File

@@ -184,9 +184,17 @@ export async function runUpdate(state: ConfigState) {
state.updateRunning = true;
state.lastError = null;
try {
await state.client.request("update.run", {
const res = await state.client.request<{
ok?: boolean;
result?: { status?: string; reason?: string };
}>("update.run", {
sessionKey: state.applySessionKey,
});
if (res && res.ok === false) {
const status = res.result?.status ?? "error";
const reason = res.result?.reason ?? "Update failed.";
state.lastError = `Update ${status}: ${reason}`;
}
} catch (err) {
state.lastError = String(err);
} finally {

View File

@@ -0,0 +1,62 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { HealthSummary } from "../types.ts";
/** Default fallback returned when the gateway is unreachable or returns null. */
const HEALTH_FALLBACK: HealthSummary = {
ok: false,
ts: 0,
durationMs: 0,
heartbeatSeconds: 0,
defaultAgentId: "",
agents: [],
sessions: { path: "", count: 0, recent: [] },
};
/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */
export type HealthState = {
client: GatewayBrowserClient | null;
connected: boolean;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
};
/**
* Fetch the gateway health summary.
*
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
* convention). Returns a fully-typed {@link HealthSummary}; on failure the
* caller receives a safe fallback with `ok: false` rather than `null`.
*/
export async function loadHealth(client: GatewayBrowserClient): Promise<HealthSummary> {
try {
const result = await client.request<HealthSummary>("health", {});
return result ?? HEALTH_FALLBACK;
} catch {
return HEALTH_FALLBACK;
}
}
/**
* State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}).
*
* Populates `healthResult` / `healthError` on the provided state slice and
* toggles `healthLoading` around the request.
*/
export async function loadHealthState(state: HealthState): Promise<void> {
if (!state.client || !state.connected) {
return;
}
if (state.healthLoading) {
return;
}
state.healthLoading = true;
state.healthError = null;
try {
state.healthResult = await loadHealth(state.client);
} catch (err) {
state.healthError = String(err);
} finally {
state.healthLoading = false;
}
}

View File

@@ -0,0 +1,18 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
/**
* Fetch the model catalog from the gateway.
*
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
* convention). Returns an array of {@link ModelCatalogEntry}; on failure the
* caller receives an empty array rather than throwing.
*/
export async function loadModels(client: GatewayBrowserClient): Promise<ModelCatalogEntry[]> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
return result?.models ?? [];
} catch {
return [];
}
}

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

@@ -7,8 +7,11 @@ const allowedTags = [
"b",
"blockquote",
"br",
"button",
"code",
"del",
"details",
"div",
"em",
"h1",
"h2",
@@ -20,7 +23,9 @@ const allowedTags = [
"ol",
"p",
"pre",
"span",
"strong",
"summary",
"table",
"tbody",
"td",
@@ -31,7 +36,19 @@ const allowedTags = [
"img",
];
const allowedAttrs = ["class", "href", "rel", "target", "title", "start", "src", "alt"];
const allowedAttrs = [
"class",
"href",
"rel",
"target",
"title",
"start",
"src",
"alt",
"data-code",
"type",
"aria-label",
];
const sanitizeOptions = {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
@@ -44,6 +61,7 @@ const MARKDOWN_PARSE_LIMIT = 40_000;
const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const markdownCache = new Map<string, string>();
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key);
@@ -82,6 +100,9 @@ function installHooks() {
}
node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank");
if (href.toLowerCase().includes("tail")) {
node.classList.add(TAIL_LINK_BLUR_CLASS);
}
});
}
@@ -138,6 +159,43 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const htmlEscapeRenderer = new marked.Renderer();
htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text);
htmlEscapeRenderer.code = ({
text,
lang,
escaped,
}: {
text: string;
lang?: string;
escaped?: boolean;
}) => {
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
const safeText = escaped ? text : escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
const attrSafe = text
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button>`;
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
const trimmed = text.trim();
const isJson =
lang === "json" ||
(!lang &&
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
if (isJson) {
const lineCount = text.split("\n").length;
const label = lineCount > 1 ? `JSON &middot; ${lineCount} lines` : "JSON";
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
}
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")

View File

@@ -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<Tab, string> = {
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":

View File

@@ -2,18 +2,20 @@ const KEY = "openclaw.control.settings.v1";
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 (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string;
};
@@ -36,11 +38,13 @@ export function loadSettings(): UiSettings {
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
};
@@ -50,6 +54,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,
);
return {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
@@ -65,10 +73,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:
@@ -83,6 +89,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

View File

@@ -1,6 +1,14 @@
import { afterEach, beforeEach } from "vitest";
import "../app.ts";
import type { OpenClawApp } from "../app.ts";
import type { GatewayHelloOk } from "../gateway.ts";
type MountHarnessApp = OpenClawApp & {
client?: { stop: () => void } | null;
connected?: boolean;
hello?: GatewayHelloOk | null;
lastError?: string | null;
};
export function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
@@ -9,6 +17,14 @@ export function mountApp(pathname: string) {
// no-op: avoid real gateway WS connections in browser tests
};
document.body.append(app);
const mounted = app as MountHarnessApp;
// Browser tests exercise rendered UI behavior, not live gateway transport.
// Force a connected shell and neutralize any background client started by lifecycle hooks.
mounted.client?.stop();
mounted.client = null;
mounted.connected = true;
mounted.lastError = null;
mounted.hello = mounted.hello ?? null;
return app;
}

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,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<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 +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);
};

View File

@@ -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<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;
}
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";
}

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

@@ -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;
@@ -442,7 +413,7 @@ export type {
export type CronSchedule =
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number };
| { kind: "cron"; expr: string; tz?: string };
export type CronSessionTarget = "main" | "isolated";
export type CronWakeMode = "next-heartbeat" | "now";
@@ -452,7 +423,6 @@ export type CronPayload =
| {
kind: "agentTurn";
message: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
lightContext?: boolean;
@@ -510,58 +480,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 +544,35 @@ 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 LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
@@ -625,3 +583,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;
};

View File

@@ -0,0 +1,195 @@
import { html, nothing } from "lit";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import {
buildModelOptions,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
import type { AgentsPanel } from "./agents.ts";
export function renderAgentOverview(params: {
agent: AgentsListResult["agents"][number];
basePath: string;
defaultId: string | null;
configForm: Record<string, unknown> | null;
agentFilesList: AgentsFilesListResult | null;
agentIdentity: AgentIdentityResult | null;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onConfigReload: () => void;
onConfigSave: () => void;
onModelChange: (agentId: string, modelId: string | null) => void;
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
onSelectPanel: (panel: AgentsPanel) => void;
}) {
const {
agent,
configForm,
agentFilesList,
configLoading,
configSaving,
configDirty,
onConfigReload,
onConfigSave,
onModelChange,
onModelFallbacksChange,
onSelectPanel,
} = params;
const config = resolveAgentConfig(configForm, agent.id);
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
const model = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const defaultModel = resolveModelLabel(config.defaults?.model);
const entryPrimary = resolveModelPrimary(config.entry?.model);
const defaultPrimary =
resolveModelPrimary(config.defaults?.model) ||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
const effectivePrimary = entryPrimary ?? defaultPrimary ?? null;
const modelFallbacks = resolveModelFallbacks(config.entry?.model);
const fallbackChips = modelFallbacks ?? [];
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
const isDefault = Boolean(params.defaultId && agent.id === params.defaultId);
const disabled = !configForm || configLoading || configSaving;
const removeChip = (index: number) => {
const next = fallbackChips.filter((_, i) => i !== index);
onModelFallbacksChange(agent.id, next);
};
const handleChipKeydown = (e: KeyboardEvent) => {
const input = e.target as HTMLInputElement;
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const parsed = parseFallbackList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";
}
}
};
return html`
<section class="card">
<div class="card-title">Overview</div>
<div class="card-sub">Workspace paths and identity metadata.</div>
<div class="agents-overview-grid" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Workspace</div>
<div>
<button
type="button"
class="workspace-link mono"
@click=${() => onSelectPanel("files")}
title="Open Files tab"
>${workspace}</button>
</div>
</div>
<div class="agent-kv">
<div class="label">Primary Model</div>
<div class="mono">${model}</div>
</div>
<div class="agent-kv">
<div class="label">Skills Filter</div>
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
</div>
</div>
${
configDirty
? html`
<div class="callout warn" style="margin-top: 16px">You have unsaved config changes.</div>
`
: nothing
}
<div class="agent-model-select" style="margin-top: 20px;">
<div class="label">Model Selection</div>
<div class="agent-model-fields">
<label class="field">
<span>Primary model${isDefault ? " (default)" : ""}</span>
<select
.value=${isDefault ? (effectivePrimary ?? "") : (entryPrimary ?? "")}
?disabled=${disabled}
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? nothing
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
</select>
</label>
<div class="field">
<span>Fallbacks</span>
<div class="agent-chip-input" @click=${(e: Event) => {
const container = e.currentTarget as HTMLElement;
const input = container.querySelector("input");
if (input) {
input.focus();
}
}}>
${fallbackChips.map(
(chip, i) => html`
<span class="chip">
${chip}
<button
type="button"
class="chip-remove"
?disabled=${disabled}
@click=${() => removeChip(i)}
>&times;</button>
</span>
`,
)}
<input
?disabled=${disabled}
placeholder=${fallbackChips.length === 0 ? "provider/model" : ""}
@keydown=${handleChipKeydown}
@blur=${(e: Event) => {
const input = e.target as HTMLInputElement;
const parsed = parseFallbackList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";
}
}}
/>
</div>
</div>
</div>
<div class="agent-model-actions">
<button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
Reload Config
</button>
<button
type="button"
class="btn btn--sm primary"
?disabled=${configSaving || !configDirty}
@click=${onConfigSave}
>
${configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
`;
}

View File

@@ -1,5 +1,8 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import {
formatCronPayload,
formatCronSchedule,
@@ -36,8 +39,8 @@ function renderAgentContextCard(context: AgentContext, subtitle: string) {
<div>${context.identityName}</div>
</div>
<div class="agent-kv">
<div class="label">Identity Emoji</div>
<div>${context.identityEmoji}</div>
<div class="label">Identity Avatar</div>
<div>${context.identityAvatar}</div>
</div>
<div class="agent-kv">
<div class="label">Skills Filter</div>
@@ -182,7 +185,7 @@ export function renderAgentChannels(params: {
const status = summary.total
? `${summary.connected}/${summary.total} connected`
: "no accounts";
const config = summary.configured
const configLabel = summary.configured
? `${summary.configured} configured`
: "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
@@ -199,8 +202,23 @@ export function renderAgentChannels(params: {
</div>
<div class="list-meta">
<div>${status}</div>
<div>${config}</div>
<div>${configLabel}</div>
<div>${enabled}</div>
${
summary.configured === 0
? html`
<div>
<a
href="https://docs.openclaw.ai/channels"
target="_blank"
rel="noopener"
style="color: var(--accent); font-size: 12px"
>Setup guide</a
>
</div>
`
: nothing
}
${
extras.length > 0
? extras.map(
@@ -228,6 +246,7 @@ export function renderAgentCron(params: {
loading: boolean;
error: string | null;
onRefresh: () => void;
onRunNow: (jobId: string) => void;
}) {
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
return html`
@@ -297,6 +316,12 @@ export function renderAgentCron(params: {
<div class="list-meta">
<div class="mono">${formatCronState(job)}</div>
<div class="muted">${formatCronPayload(job)}</div>
<button
class="btn btn--sm"
style="margin-top: 6px;"
?disabled=${!job.enabled}
@click=${() => params.onRunNow(job.id)}
>Run Now</button>
</div>
</div>
`,
@@ -389,6 +414,21 @@ export function renderAgentFiles(params: {
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
title="Preview rendered markdown"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn
.closest(".agent-files-editor")
?.querySelector("dialog");
if (dialog) {
dialog.showModal();
}
}}
>
${icons.eye} Preview
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@@ -425,6 +465,30 @@ export function renderAgentFiles(params: {
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
<button
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>${icons.x} Close</button>
</div>
<div class="md-preview-dialog__body sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(draft))}
</div>
</div>
</dialog>
`
}
</div>

View File

@@ -1,6 +1,6 @@
import { html, nothing } from "lit";
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
import type { SkillStatusEntry, SkillStatusReport } from "../types.ts";
import {
isAllowedByPolicy,
matchesList,
@@ -23,9 +23,6 @@ export function renderAgentTools(params: {
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
onProfileChange: (agentId: string, profile: string | null, clearAllow: boolean) => void;
onOverridesChange: (agentId: string, alsoAllow: string[], deny: string[]) => void;
onConfigReload: () => void;
@@ -53,17 +50,7 @@ export function renderAgentTools(params: {
const basePolicy = hasAgentAllow
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
: (resolveToolProfile(profile) ?? undefined);
const sections =
params.toolsCatalogResult?.groups?.length &&
params.toolsCatalogResult.agentId === params.agentId
? params.toolsCatalogResult.groups
: TOOL_SECTIONS;
const profileOptions =
params.toolsCatalogResult?.profiles?.length &&
params.toolsCatalogResult.agentId === params.agentId
? params.toolsCatalogResult.profiles
: PROFILE_OPTIONS;
const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id));
const toolIds = TOOL_SECTIONS.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
@@ -152,15 +139,6 @@ export function renderAgentTools(params: {
</div>
</div>
${
params.toolsCatalogError
? html`
<div class="callout warn" style="margin-top: 12px">
Could not load runtime tool catalog. Showing fallback list.
</div>
`
: nothing
}
${
!params.configForm
? html`
@@ -213,7 +191,7 @@ export function renderAgentTools(params: {
<div class="agent-tools-presets" style="margin-top: 16px;">
<div class="label">Quick Presets</div>
<div class="agent-tools-buttons">
${profileOptions.map(
${PROFILE_OPTIONS.map(
(option) => html`
<button
class="btn btn--sm ${profile === option.id ? "active" : ""}"
@@ -235,49 +213,18 @@ export function renderAgentTools(params: {
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${sections.map(
${TOOL_SECTIONS.map(
(section) =>
html`
<div class="agent-tools-section">
<div class="agent-tools-header">
${section.label}
${
"source" in section && section.source === "plugin"
? html`
<span class="mono" style="margin-left: 6px">plugin</span>
`
: nothing
}
</div>
<div class="agent-tools-header">${section.label}</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
const catalogTool = tool as {
source?: "core" | "plugin";
pluginId?: string;
optional?: boolean;
};
const source =
catalogTool.source === "plugin"
? catalogTool.pluginId
? `plugin:${catalogTool.pluginId}`
: "plugin"
: "core";
const isOptional = catalogTool.optional === true;
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title mono">
${tool.label}
<span class="mono" style="margin-left: 8px; opacity: 0.8;">${source}</span>
${
isOptional
? html`
<span class="mono" style="margin-left: 6px; opacity: 0.8">optional</span>
`
: nothing
}
</div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
</div>
<label class="cfg-toggle">
@@ -298,13 +245,6 @@ export function renderAgentTools(params: {
`,
)}
</div>
${
params.toolsCatalogLoading
? html`
<div class="card-sub" style="margin-top: 10px">Refreshing tool catalog…</div>
`
: nothing
}
</section>
`;
}
@@ -361,17 +301,27 @@ export function renderAgentSkills(params: {
}
</div>
</div>
<div class="row" style="gap: 8px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
Use All
</button>
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onDisableAll(params.agentId)}
>
Disable All
</button>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
Enable All
</button>
<button
class="btn btn--sm"
?disabled=${!editable}
@click=${() => params.onDisableAll(params.agentId)}
>
Disable All
</button>
<button
class="btn btn--sm"
?disabled=${!editable || !usingAllowlist}
@click=${() => params.onClear(params.agentId)}
title="Remove per-agent allowlist and use all skills"
>
Reset
</button>
</div>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>

View File

@@ -1,8 +1,4 @@
import { html } from "lit";
import {
listCoreToolSections,
PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS,
} from "../../../../src/agents/tool-catalog.js";
import {
expandToolGroups,
normalizeToolName,
@@ -10,9 +6,96 @@ import {
} from "../../../../src/agents/tool-policy-shared.js";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
export const TOOL_SECTIONS = listCoreToolSections();
export const TOOL_SECTIONS = [
{
id: "fs",
label: "Files",
tools: [
{ id: "read", label: "read", description: "Read file contents" },
{ id: "write", label: "write", description: "Create or overwrite files" },
{ id: "edit", label: "edit", description: "Make precise edits" },
{ id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" },
],
},
{
id: "runtime",
label: "Runtime",
tools: [
{ id: "exec", label: "exec", description: "Run shell commands" },
{ id: "process", label: "process", description: "Manage background processes" },
],
},
{
id: "web",
label: "Web",
tools: [
{ id: "web_search", label: "web_search", description: "Search the web" },
{ id: "web_fetch", label: "web_fetch", description: "Fetch web content" },
],
},
{
id: "memory",
label: "Memory",
tools: [
{ id: "memory_search", label: "memory_search", description: "Semantic search" },
{ id: "memory_get", label: "memory_get", description: "Read memory files" },
],
},
{
id: "sessions",
label: "Sessions",
tools: [
{ id: "sessions_list", label: "sessions_list", description: "List sessions" },
{ id: "sessions_history", label: "sessions_history", description: "Session history" },
{ id: "sessions_send", label: "sessions_send", description: "Send to session" },
{ id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" },
{ id: "session_status", label: "session_status", description: "Session status" },
],
},
{
id: "ui",
label: "UI",
tools: [
{ id: "browser", label: "browser", description: "Control web browser" },
{ id: "canvas", label: "canvas", description: "Control canvases" },
],
},
{
id: "messaging",
label: "Messaging",
tools: [{ id: "message", label: "message", description: "Send messages" }],
},
{
id: "automation",
label: "Automation",
tools: [
{ id: "cron", label: "cron", description: "Schedule tasks" },
{ id: "gateway", label: "gateway", description: "Gateway control" },
],
},
{
id: "nodes",
label: "Nodes",
tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }],
},
{
id: "agents",
label: "Agents",
tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }],
},
{
id: "media",
label: "Media",
tools: [{ id: "image", label: "image", description: "Image understanding" }],
},
];
export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS;
export const PROFILE_OPTIONS = [
{ id: "minimal", label: "Minimal" },
{ id: "coding", label: "Coding" },
{ id: "messaging", label: "Messaging" },
{ id: "full", label: "Full" },
] as const;
type ToolPolicy = {
allow?: string[];
@@ -55,6 +138,30 @@ export function normalizeAgentLabel(agent: {
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
}
const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i;
export function resolveAgentAvatarUrl(
agent: { identity?: { avatar?: string; avatarUrl?: string } },
agentIdentity?: AgentIdentityResult | null,
): string | null {
const url =
agentIdentity?.avatar?.trim() ??
agent.identity?.avatarUrl?.trim() ??
agent.identity?.avatar?.trim();
if (!url) {
return null;
}
if (AVATAR_URL_RE.test(url)) {
return url;
}
return null;
}
export function agentLogoUrl(basePath: string): string {
const base = basePath?.trim() ? basePath.replace(/\/$/, "") : "";
return base ? `${base}/favicon.svg` : "/favicon.svg";
}
function isLikelyEmoji(value: string) {
const trimmed = value.trim();
if (!trimmed) {
@@ -106,6 +213,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) {
return defaultId && agentId === defaultId ? "default" : null;
}
export function agentAvatarHue(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i += 1) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return ((hash % 360) + 360) % 360;
}
export function formatBytes(bytes?: number) {
if (bytes == null || !Number.isFinite(bytes)) {
return "-";
@@ -138,7 +253,7 @@ export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
identityAvatar: string;
skillsLabel: string;
isDefault: boolean;
};
@@ -164,14 +279,14 @@ export function buildAgentContext(
agent.name?.trim() ||
config.entry?.name ||
agent.id;
const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-";
const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—";
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
return {
workspace,
model: modelLabel,
identityName,
identityEmoji,
identityAvatar,
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
isDefault: Boolean(defaultId && agent.id === defaultId),
};

View File

@@ -7,66 +7,72 @@ import type {
CronJob,
CronStatus,
SkillStatusReport,
ToolsCatalogResult,
} from "../types.ts";
import { renderAgentOverview } from "./agents-panels-overview.ts";
import {
renderAgentFiles,
renderAgentChannels,
renderAgentCron,
} from "./agents-panels-status-files.ts";
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
import {
agentBadgeText,
buildAgentContext,
buildModelOptions,
normalizeAgentLabel,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentEmoji,
resolveEffectiveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
export type ConfigState = {
form: Record<string, unknown> | null;
loading: boolean;
saving: boolean;
dirty: boolean;
};
export type ChannelsState = {
snapshot: ChannelsStatusSnapshot | null;
loading: boolean;
error: string | null;
lastSuccess: number | null;
};
export type CronState = {
status: CronStatus | null;
jobs: CronJob[];
loading: boolean;
error: string | null;
};
export type AgentFilesState = {
list: AgentsFilesListResult | null;
loading: boolean;
error: string | null;
active: string | null;
contents: Record<string, string>;
drafts: Record<string, string>;
saving: boolean;
};
export type AgentSkillsState = {
report: SkillStatusReport | null;
loading: boolean;
error: string | null;
agentId: string | null;
filter: string;
};
export type AgentsProps = {
basePath: string;
loading: boolean;
error: string | null;
agentsList: AgentsListResult | null;
selectedAgentId: string | null;
activePanel: AgentsPanel;
configForm: Record<string, unknown> | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
channelsLoading: boolean;
channelsError: string | null;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsLastSuccess: number | null;
cronLoading: boolean;
cronStatus: CronStatus | null;
cronJobs: CronJob[];
cronError: string | null;
agentFilesLoading: boolean;
agentFilesError: string | null;
agentFilesList: AgentsFilesListResult | null;
agentFileActive: string | null;
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileSaving: boolean;
config: ConfigState;
channels: ChannelsState;
cron: CronState;
agentFiles: AgentFilesState;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkillsLoading: boolean;
agentSkillsReport: SkillStatusReport | null;
agentSkillsError: string | null;
agentSkillsAgentId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
skillsFilter: string;
agentSkills: AgentSkillsState;
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void;
@@ -83,20 +89,13 @@ export type AgentsProps = {
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
onChannelsRefresh: () => void;
onCronRefresh: () => void;
onCronRunNow: (jobId: string) => void;
onSkillsFilterChange: (next: string) => void;
onSkillsRefresh: () => void;
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
onAgentSkillsClear: (agentId: string) => void;
onAgentSkillsDisableAll: (agentId: string) => void;
};
export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
skillsLabel: string;
isDefault: boolean;
onSetDefault: (agentId: string) => void;
};
export function renderAgents(props: AgentsProps) {
@@ -107,49 +106,96 @@ export function renderAgents(props: AgentsProps) {
? (agents.find((agent) => agent.id === selectedId) ?? null)
: null;
const channelEntryCount = props.channels.snapshot
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
: null;
const cronJobCount = selectedId
? props.cron.jobs.filter((j) => j.agentId === selectedId).length
: null;
const tabCounts: Record<string, number | null> = {
files: props.agentFiles.list?.files?.length ?? null,
skills: props.agentSkills.report?.skills?.length ?? null,
channels: channelEntryCount,
cron: cronJobCount || null,
};
return html`
<div class="agents-layout">
<section class="card agents-sidebar">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Agents</div>
<div class="card-sub">${agents.length} configured.</div>
<section class="agents-toolbar">
<div class="agents-toolbar-row">
<span class="agents-toolbar-label">Agent</span>
<div class="agents-control-row">
<div class="agents-control-select">
<select
class="agents-select"
.value=${selectedId ?? ""}
?disabled=${props.loading || agents.length === 0}
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
>
${
agents.length === 0
? html`
<option value="">No agents</option>
`
: agents.map(
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selectedId}>
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
</option>
`,
)
}
</select>
</div>
<div class="agents-control-actions">
${
selectedAgent
? html`
<div class="agent-actions-wrap">
<button
class="agent-actions-toggle"
type="button"
@click=${() => {
actionsMenuOpen = !actionsMenuOpen;
}}
>⋯</button>
${
actionsMenuOpen
? html`
<div class="agent-actions-menu">
<button type="button" @click=${() => {
void navigator.clipboard.writeText(selectedAgent.id);
actionsMenuOpen = false;
}}>Copy agent ID</button>
<button
type="button"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => {
props.onSetDefault(selectedAgent.id);
actionsMenuOpen = false;
}}
>
${defaultId && selectedAgent.id === defaultId ? "Already default" : "Set as default"}
</button>
</div>
`
: nothing
}
</div>
`
: nothing
}
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div>
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
: nothing
}
<div class="agent-list" style="margin-top: 12px;">
${
agents.length === 0
? html`
<div class="muted">No agents found.</div>
`
: agents.map((agent) => {
const badge = agentBadgeText(agent.id, defaultId);
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
return html`
<button
type="button"
class="agent-row ${selectedId === agent.id ? "active" : ""}"
@click=${() => props.onSelectAgent(agent.id)}
>
<div class="agent-avatar">${emoji || normalizeAgentLabel(agent).slice(0, 1)}</div>
<div class="agent-info">
<div class="agent-title">${normalizeAgentLabel(agent)}</div>
<div class="agent-sub mono">${agent.id}</div>
</div>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
</button>
`;
})
}
</div>
</section>
<section class="agents-main">
${
@@ -161,29 +207,26 @@ export function renderAgents(props: AgentsProps) {
</div>
`
: html`
${renderAgentHeader(
selectedAgent,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
)}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
${
props.activePanel === "overview"
? renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
defaultId,
configForm: props.configForm,
agentFilesList: props.agentFilesList,
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.configLoading,
configSaving: props.configSaving,
configDirty: props.configDirty,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
@@ -191,13 +234,13 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFilesList,
agentFilesLoading: props.agentFilesLoading,
agentFilesError: props.agentFilesError,
agentFileActive: props.agentFileActive,
agentFileContents: props.agentFileContents,
agentFileDrafts: props.agentFileDrafts,
agentFileSaving: props.agentFileSaving,
agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
@@ -210,13 +253,10 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.configForm,
configLoading: props.configLoading,
configSaving: props.configSaving,
configDirty: props.configDirty,
toolsCatalogLoading: props.toolsCatalogLoading,
toolsCatalogError: props.toolsCatalogError,
toolsCatalogResult: props.toolsCatalogResult,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
@@ -228,15 +268,15 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkillsReport,
loading: props.agentSkillsLoading,
error: props.agentSkillsError,
activeAgentId: props.agentSkillsAgentId,
configForm: props.configForm,
configLoading: props.configLoading,
configSaving: props.configSaving,
configDirty: props.configDirty,
filter: props.skillsFilter,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
@@ -252,16 +292,16 @@ export function renderAgents(props: AgentsProps) {
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.configForm,
props.agentFilesList,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.configForm,
snapshot: props.channelsSnapshot,
loading: props.channelsLoading,
error: props.channelsError,
lastSuccess: props.channelsLastSuccess,
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
})
: nothing
@@ -271,17 +311,18 @@ export function renderAgents(props: AgentsProps) {
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.configForm,
props.agentFilesList,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cronJobs,
status: props.cronStatus,
loading: props.cronLoading,
error: props.cronError,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
})
: nothing
}
@@ -292,33 +333,13 @@ export function renderAgents(props: AgentsProps) {
`;
}
function renderAgentHeader(
agent: AgentsListResult["agents"][number],
defaultId: string | null,
agentIdentity: AgentIdentityResult | null,
) {
const badge = agentBadgeText(agent.id, defaultId);
const displayName = normalizeAgentLabel(agent);
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
const emoji = resolveAgentEmoji(agent, agentIdentity);
return html`
<section class="card agent-header">
<div class="agent-header-main">
<div class="agent-avatar agent-avatar--lg">${emoji || displayName.slice(0, 1)}</div>
<div>
<div class="card-title">${displayName}</div>
<div class="card-sub">${subtitle}</div>
</div>
</div>
<div class="agent-header-meta">
<div class="mono">${agent.id}</div>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
</div>
</section>
`;
}
let actionsMenuOpen = false;
function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) {
function renderAgentTabs(
active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void,
counts: Record<string, number | null>,
) {
const tabs: Array<{ id: AgentsPanel; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "files", label: "Files" },
@@ -336,164 +357,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) =>
type="button"
@click=${() => onSelect(tab.id)}
>
${tab.label}
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
</button>
`,
)}
</div>
`;
}
function renderAgentOverview(params: {
agent: AgentsListResult["agents"][number];
defaultId: string | null;
configForm: Record<string, unknown> | null;
agentFilesList: AgentsFilesListResult | null;
agentIdentity: AgentIdentityResult | null;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onConfigReload: () => void;
onConfigSave: () => void;
onModelChange: (agentId: string, modelId: string | null) => void;
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
}) {
const {
agent,
configForm,
agentFilesList,
agentIdentity,
agentIdentityLoading,
agentIdentityError,
configLoading,
configSaving,
configDirty,
onConfigReload,
onConfigSave,
onModelChange,
onModelFallbacksChange,
} = params;
const config = resolveAgentConfig(configForm, agent.id);
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
const model = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const defaultModel = resolveModelLabel(config.defaults?.model);
const modelPrimary =
resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null);
const defaultPrimary =
resolveModelPrimary(config.defaults?.model) ||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
const effectivePrimary = modelPrimary ?? defaultPrimary ?? null;
const modelFallbacks = resolveEffectiveModelFallbacks(
config.entry?.model,
config.defaults?.model,
);
const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : "";
const identityName =
agentIdentity?.name?.trim() ||
agent.identity?.name?.trim() ||
agent.name?.trim() ||
config.entry?.name ||
"-";
const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity);
const identityEmoji = resolvedEmoji || "-";
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
const identityStatus = agentIdentityLoading
? "Loading…"
: agentIdentityError
? "Unavailable"
: "";
const isDefault = Boolean(params.defaultId && agent.id === params.defaultId);
return html`
<section class="card">
<div class="card-title">Overview</div>
<div class="card-sub">Workspace paths and identity metadata.</div>
<div class="agents-overview-grid" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Workspace</div>
<div class="mono">${workspace}</div>
</div>
<div class="agent-kv">
<div class="label">Primary Model</div>
<div class="mono">${model}</div>
</div>
<div class="agent-kv">
<div class="label">Identity Name</div>
<div>${identityName}</div>
${identityStatus ? html`<div class="agent-kv-sub muted">${identityStatus}</div>` : nothing}
</div>
<div class="agent-kv">
<div class="label">Default</div>
<div>${isDefault ? "yes" : "no"}</div>
</div>
<div class="agent-kv">
<div class="label">Identity Emoji</div>
<div>${identityEmoji}</div>
</div>
<div class="agent-kv">
<div class="label">Skills Filter</div>
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
</div>
</div>
<div class="agent-model-select" style="margin-top: 20px;">
<div class="label">Model Selection</div>
<div class="row" style="gap: 12px; flex-wrap: wrap;">
<label class="field" style="min-width: 260px; flex: 1;">
<span>Primary model${isDefault ? " (default)" : ""}</span>
<select
.value=${effectivePrimary ?? ""}
?disabled=${!configForm || configLoading || configSaving}
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? nothing
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
</select>
</label>
<label class="field" style="min-width: 260px; flex: 1;">
<span>Fallbacks (comma-separated)</span>
<input
.value=${fallbackText}
?disabled=${!configForm || configLoading || configSaving}
placeholder="provider/model, provider/model"
@input=${(e: Event) =>
onModelFallbacksChange(
agent.id,
parseFallbackList((e.target as HTMLInputElement).value),
)}
/>
</label>
</div>
<div class="row" style="justify-content: flex-end; gap: 8px;">
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
Reload Config
</button>
<button
class="btn btn--sm primary"
?disabled=${configSaving || !configDirty}
@click=${onConfigSave}
>
${configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
`;
}

View File

@@ -0,0 +1,33 @@
import { html } from "lit";
import { icons } from "../icons.ts";
import type { Tab } from "../navigation.ts";
export type BottomTabsProps = {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
};
const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [
{ id: "overview", label: "Dashboard", icon: "barChart" },
{ id: "chat", label: "Chat", icon: "messageSquare" },
{ id: "sessions", label: "Sessions", icon: "fileText" },
{ id: "config", label: "Settings", icon: "settings" },
];
export function renderBottomTabs(props: BottomTabsProps) {
return html`
<nav class="bottom-tabs">
${BOTTOM_TABS.map(
(tab) => html`
<button
class="bottom-tab ${props.activeTab === tab.id ? "bottom-tab--active" : ""}"
@click=${() => props.onTabChange(tab.id)}
>
<span class="bottom-tab__icon">${icons[tab.icon]}</span>
<span class="bottom-tab__label">${tab.label}</span>
</button>
`,
)}
</nav>
`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import { t } from "../../i18n/index.ts";
import { icons, type IconName } from "../icons.ts";
type PaletteItem = {
id: string;
label: string;
icon: IconName;
category: "search" | "navigation" | "skills";
action: string;
description?: string;
};
const PALETTE_ITEMS: PaletteItem[] = [
{
id: "status",
label: "/status",
icon: "radio",
category: "search",
action: "/status",
description: "Show current status",
},
{
id: "models",
label: "/model",
icon: "monitor",
category: "search",
action: "/model",
description: "Show/set model",
},
{
id: "usage",
label: "/usage",
icon: "barChart",
category: "search",
action: "/usage",
description: "Show usage",
},
{
id: "think",
label: "/think",
icon: "brain",
category: "search",
action: "/think",
description: "Set thinking level",
},
{
id: "reset",
label: "/reset",
icon: "loader",
category: "search",
action: "/reset",
description: "Reset session",
},
{
id: "help",
label: "/help",
icon: "book",
category: "search",
action: "/help",
description: "Show help",
},
{
id: "nav-overview",
label: "Overview",
icon: "barChart",
category: "navigation",
action: "nav:overview",
},
{
id: "nav-sessions",
label: "Sessions",
icon: "fileText",
category: "navigation",
action: "nav:sessions",
},
{
id: "nav-cron",
label: "Scheduled",
icon: "scrollText",
category: "navigation",
action: "nav:cron",
},
{ id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" },
{
id: "nav-config",
label: "Settings",
icon: "settings",
category: "navigation",
action: "nav:config",
},
{
id: "nav-agents",
label: "Agents",
icon: "folder",
category: "navigation",
action: "nav:agents",
},
{
id: "skill-shell",
label: "Shell Command",
icon: "monitor",
category: "skills",
action: "/skill shell",
description: "Run shell",
},
{
id: "skill-debug",
label: "Debug Mode",
icon: "bug",
category: "skills",
action: "/verbose full",
description: "Toggle debug",
},
];
export type CommandPaletteProps = {
open: boolean;
query: string;
activeIndex: number;
onToggle: () => void;
onQueryChange: (query: string) => void;
onActiveIndexChange: (index: number) => void;
onNavigate: (tab: string) => void;
onSlashCommand: (command: string) => void;
};
function filteredItems(query: string): PaletteItem[] {
if (!query) {
return PALETTE_ITEMS;
}
const q = query.toLowerCase();
return PALETTE_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
);
}
function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
const map = new Map<string, PaletteItem[]>();
for (const item of items) {
const group = map.get(item.category) ?? [];
group.push(item);
map.set(item.category, group);
}
return [...map.entries()];
}
function selectItem(item: PaletteItem, props: CommandPaletteProps) {
if (item.action.startsWith("nav:")) {
props.onNavigate(item.action.slice(4));
} else {
props.onSlashCommand(item.action);
}
props.onToggle();
}
function scrollActiveIntoView() {
requestAnimationFrame(() => {
const el = document.querySelector(".cmd-palette__item--active");
el?.scrollIntoView({ block: "nearest" });
});
}
function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
const items = filteredItems(props.query);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
props.onActiveIndexChange((props.activeIndex + 1) % items.length);
scrollActiveIntoView();
break;
case "ArrowUp":
e.preventDefault();
props.onActiveIndexChange((props.activeIndex - 1 + items.length) % items.length);
scrollActiveIntoView();
break;
case "Enter":
e.preventDefault();
if (items[props.activeIndex]) {
selectItem(items[props.activeIndex], props);
}
break;
case "Escape":
e.preventDefault();
props.onToggle();
break;
}
}
const CATEGORY_LABELS: Record<string, string> = {
search: "Search",
navigation: "Navigation",
skills: "Skills",
};
function focusInput(el: Element | undefined) {
if (el) {
requestAnimationFrame(() => (el as HTMLInputElement).focus());
}
}
export function renderCommandPalette(props: CommandPaletteProps) {
if (!props.open) {
return nothing;
}
const items = filteredItems(props.query);
const grouped = groupItems(items);
return html`
<div class="cmd-palette-overlay" @click=${() => props.onToggle()}>
<div
class="cmd-palette"
@click=${(e: Event) => e.stopPropagation()}
@keydown=${(e: KeyboardEvent) => handleKeydown(e, props)}
>
<input
${ref(focusInput)}
class="cmd-palette__input"
placeholder="${t("overview.palette.placeholder")}"
.value=${props.query}
@input=${(e: Event) => {
props.onQueryChange((e.target as HTMLInputElement).value);
props.onActiveIndexChange(0);
}}
/>
<div class="cmd-palette__results">
${
grouped.length === 0
? html`<div class="muted" style="padding: 12px 16px">${t("overview.palette.noResults")}</div>`
: grouped.map(
([category, groupedItems]) => html`
<div class="cmd-palette__group-label">${CATEGORY_LABELS[category] ?? category}</div>
${groupedItems.map((item) => {
const globalIndex = items.indexOf(item);
const isActive = globalIndex === props.activeIndex;
return html`
<div
class="cmd-palette__item ${isActive ? "cmd-palette__item--active" : ""}"
@click=${(e: Event) => {
e.stopPropagation();
selectItem(item, props);
}}
@mouseenter=${() => props.onActiveIndexChange(globalIndex)}
>
<span class="nav-item__icon">${icons[item.icon]}</span>
<span>${item.label}</span>
${
item.description
? html`<span class="cmd-palette__item-desc muted">${item.description}</span>`
: nothing
}
</div>
`;
})}
`,
)
}
</div>
</div>
</div>
`;
}

View File

@@ -440,6 +440,9 @@ export function renderNode(params: {
});
}
}
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea({ schema, value, path, hints, disabled, showLabel, onPatch });
}
// Enum - use segmented for small, dropdown for large
@@ -702,6 +705,49 @@ function renderSelect(params: {
`;
}
function renderJsonTextarea(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const fallback = jsonValue(value);
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<textarea
class="cfg-textarea"
placeholder="JSON value"
rows="3"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
</div>
`;
}
function renderObject(params: {
schema: JsonSchema;
value: unknown;

View File

@@ -1,8 +1,10 @@
import { html, nothing } from "lit";
import { html, nothing, type TemplateResult } from "lit";
import { icons } from "../icons.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
import { getTagFilters, replaceTagFilters } from "./config-search.ts";
export type ConfigProps = {
raw: string;
@@ -18,11 +20,13 @@ export type ConfigProps = {
schemaLoading: boolean;
uiHints: ConfigUiHints;
formMode: "form" | "raw";
showModeToggle?: boolean;
formValue: Record<string, unknown> | null;
originalValue: Record<string, unknown> | null;
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
streamMode: boolean;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
@@ -33,26 +37,19 @@ export type ConfigProps = {
onSave: () => void;
onApply: () => void;
onUpdate: () => void;
version: string;
theme: ThemeName;
themeMode: ThemeMode;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
gatewayUrl: string;
assistantName: string;
navRootLabel?: string;
includeSections?: string[];
excludeSections?: string[];
includeVirtualSections?: boolean;
};
const TAG_SEARCH_PRESETS = [
"security",
"auth",
"network",
"access",
"privacy",
"observability",
"performance",
"reliability",
"storage",
"models",
"media",
"automation",
"channels",
"tools",
"advanced",
] as const;
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
@@ -273,6 +270,19 @@ const sidebarIcons = {
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
__appearance__: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
@@ -281,35 +291,137 @@ const sidebarIcons = {
`,
};
// Section definitions
const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "env", label: "Environment" },
{ key: "update", label: "Updates" },
{ key: "agents", label: "Agents" },
{ key: "auth", label: "Authentication" },
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "gateway", label: "Gateway" },
{ key: "wizard", label: "Setup Wizard" },
];
type SubsectionEntry = {
key: string;
// Categorised section definitions
type SectionCategory = {
id: string;
label: string;
description?: string;
order: number;
sections: Array<{ key: string; label: string }>;
};
const ALL_SUBSECTION = "__all__";
const SECTION_CATEGORIES: SectionCategory[] = [
{
id: "core",
label: "Core",
sections: [
{ key: "env", label: "Environment" },
{ key: "auth", label: "Authentication" },
{ key: "update", label: "Updates" },
{ key: "meta", label: "Meta" },
{ key: "logging", label: "Logging" },
],
},
{
id: "ai",
label: "AI & Agents",
sections: [
{ key: "agents", label: "Agents" },
{ key: "models", label: "Models" },
{ key: "skills", label: "Skills" },
{ key: "tools", label: "Tools" },
{ key: "memory", label: "Memory" },
{ key: "session", label: "Session" },
],
},
{
id: "communication",
label: "Communication",
sections: [
{ key: "channels", label: "Channels" },
{ key: "messages", label: "Messages" },
{ key: "broadcast", label: "Broadcast" },
{ key: "talk", label: "Talk" },
{ key: "audio", label: "Audio" },
],
},
{
id: "automation",
label: "Automation",
sections: [
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "bindings", label: "Bindings" },
{ key: "cron", label: "Cron" },
{ key: "approvals", label: "Approvals" },
{ key: "plugins", label: "Plugins" },
],
},
{
id: "infrastructure",
label: "Infrastructure",
sections: [
{ key: "gateway", label: "Gateway" },
{ key: "web", label: "Web" },
{ key: "browser", label: "Browser" },
{ key: "nodeHost", label: "NodeHost" },
{ key: "canvasHost", label: "CanvasHost" },
{ key: "discovery", label: "Discovery" },
{ key: "media", label: "Media" },
],
},
{
id: "appearance",
label: "Appearance",
sections: [
{ key: "__appearance__", label: "Appearance" },
{ key: "ui", label: "UI" },
{ key: "wizard", label: "Setup Wizard" },
],
},
];
// Flat lookup: all categorised keys
const CATEGORISED_KEYS = new Set(SECTION_CATEGORIES.flatMap((c) => c.sections.map((s) => s.key)));
function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function scopeSchemaSections(
schema: JsonSchema | null,
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): JsonSchema | null {
if (!schema || schemaType(schema) !== "object" || !schema.properties) {
return schema;
}
const include = params.include;
const exclude = params.exclude;
const nextProps: Record<string, JsonSchema> = {};
for (const [key, value] of Object.entries(schema.properties)) {
if (include && include.size > 0 && !include.has(key)) {
continue;
}
if (exclude && exclude.size > 0 && exclude.has(key)) {
continue;
}
nextProps[key] = value;
}
return { ...schema, properties: nextProps };
}
function scopeUnsupportedPaths(
unsupportedPaths: string[],
params: { include?: ReadonlySet<string> | null; exclude?: ReadonlySet<string> | null },
): string[] {
const include = params.include;
const exclude = params.exclude;
if ((!include || include.size === 0) && (!exclude || exclude.size === 0)) {
return unsupportedPaths;
}
return unsupportedPaths.filter((entry) => {
if (entry === "<root>") {
return true;
}
const [top] = entry.split(".");
if (include && include.size > 0) {
return include.has(top);
}
if (exclude && exclude.size > 0) {
return !exclude.has(top);
}
return true;
});
}
function resolveSectionMeta(
key: string,
schema?: JsonSchema,
@@ -327,26 +439,6 @@ function resolveSectionMeta(
};
}
function resolveSubsections(params: {
key: string;
schema: JsonSchema | undefined;
uiHints: ConfigUiHints;
}): SubsectionEntry[] {
const { key, schema, uiHints } = params;
if (!schema || schemaType(schema) !== "object" || !schema.properties) {
return [];
}
const entries = Object.entries(schema.properties).map(([subKey, node]) => {
const hint = hintForPath([key, subKey], uiHints);
const label = hint?.label ?? node.title ?? humanize(subKey);
const description = hint?.help ?? node.description ?? "";
const order = hint?.order ?? 50;
return { key: subKey, label, description, order };
});
entries.sort((a, b) => (a.order !== b.order ? a.order - b.order : a.key.localeCompare(b.key)));
return entries;
}
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null,
@@ -402,231 +494,256 @@ function truncateValue(value: unknown, maxLen = 40): string {
return str.slice(0, maxLen - 3) + "...";
}
const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i;
const SENSITIVE_KEY_WHITELIST_RE =
/maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i;
function countSensitiveValues(formValue: Record<string, unknown> | null): number {
if (!formValue) {
return 0;
}
let count = 0;
function walk(obj: unknown, key?: string) {
if (obj == null) {
return;
}
if (typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
walk(v, k);
}
} else if (Array.isArray(obj)) {
for (const item of obj) {
walk(item);
}
} else if (
key &&
typeof obj === "string" &&
SENSITIVE_KEY_RE.test(key) &&
!SENSITIVE_KEY_WHITELIST_RE.test(key)
) {
if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) {
count++;
}
}
}
walk(formValue);
return count;
}
type ThemeOption = { id: ThemeName; label: string; description: string; icon: TemplateResult };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", description: "Chroma family", icon: icons.zap },
{ id: "knot", label: "Knot", description: "Knot family", icon: icons.link },
{ id: "dash", label: "Dash", description: "Field family", icon: icons.barChart },
];
function renderAppearanceSection(props: ConfigProps) {
const MODE_OPTIONS: Array<{
id: ThemeMode;
label: string;
description: string;
icon: TemplateResult;
}> = [
{ id: "system", label: "System", description: "Follow OS light or dark", icon: icons.monitor },
{ id: "light", label: "Light", description: "Force light mode", icon: icons.sun },
{ id: "dark", label: "Dark", description: "Force dark mode", icon: icons.moon },
];
return html`
<div class="settings-appearance">
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Theme</h3>
<p class="settings-appearance__hint">Choose a theme family.</p>
<div class="settings-theme-grid">
${THEME_OPTIONS.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.theme ? "settings-theme-card--active" : ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.theme) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
props.setTheme(opt.id, context);
}
}}
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${
opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
: nothing
}
</button>
`,
)}
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Mode</h3>
<p class="settings-appearance__hint">Choose light or dark mode for the selected theme.</p>
<div class="settings-theme-grid">
${MODE_OPTIONS.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.themeMode ? "settings-theme-card--active" : ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.themeMode) {
const context: ThemeTransitionContext = {
element: (e.currentTarget as HTMLElement) ?? undefined,
};
props.setThemeMode(opt.id, context);
}
}}
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${
opt.id === props.themeMode
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
: nothing
}
</button>
`,
)}
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Connection</h3>
<div class="settings-info-grid">
<div class="settings-info-row">
<span class="settings-info-row__label">Gateway</span>
<span class="settings-info-row__value mono">${props.gatewayUrl || "—"}</span>
</div>
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
<span class="settings-info-row__value">
<span class="settings-status-dot ${props.connected ? "settings-status-dot--ok" : ""}"></span>
${props.connected ? "Connected" : "Offline"}
</span>
</div>
${
props.assistantName
? html`
<div class="settings-info-row">
<span class="settings-info-row__label">Assistant</span>
<span class="settings-info-row__value">${props.assistantName}</span>
</div>
`
: nothing
}
</div>
</div>
</div>
`;
}
let rawRevealed = false;
let validityDismissed = false;
export function renderConfig(props: ConfigProps) {
const showModeToggle = props.showModeToggle ?? false;
const formMode = showModeToggle ? props.formMode : "form";
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema);
const includeVirtualSections = props.includeVirtualSections ?? true;
const include = props.includeSections?.length ? new Set(props.includeSections) : null;
const exclude = props.excludeSections?.length ? new Set(props.excludeSections) : null;
const rawAnalysis = analyzeConfigSchema(props.schema);
const analysis = {
schema: scopeSchemaSections(rawAnalysis.schema, { include, exclude }),
unsupportedPaths: scopeUnsupportedPaths(rawAnalysis.unsupportedPaths, { include, exclude }),
};
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
// Get available sections from schema
// Build categorised nav from schema — only include sections that exist in the schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter((s) => s.key in schemaProps);
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map((s) => s.key));
const VIRTUAL_SECTIONS = new Set(["__appearance__"]);
const visibleCategories = SECTION_CATEGORIES.map((cat) => ({
...cat,
sections: cat.sections.filter(
(s) => (includeVirtualSections && VIRTUAL_SECTIONS.has(s.key)) || s.key in schemaProps,
),
})).filter((cat) => cat.sections.length > 0);
// Catch any schema keys not in our categories
const extraSections = Object.keys(schemaProps)
.filter((k) => !knownKeys.has(k))
.filter((k) => !CATEGORISED_KEYS.has(k))
.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
const otherCategory: SectionCategory | null =
extraSections.length > 0 ? { id: "other", label: "Other", sections: extraSections } : null;
const isVirtualSection =
includeVirtualSections &&
props.activeSection != null &&
VIRTUAL_SECTIONS.has(props.activeSection);
const activeSectionSchema =
props.activeSection && analysis.schema && schemaType(analysis.schema) === "object"
props.activeSection &&
!isVirtualSection &&
analysis.schema &&
schemaType(analysis.schema) === "object"
? analysis.schema.properties?.[props.activeSection]
: undefined;
const activeSectionMeta = props.activeSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
const subsections = props.activeSection
? resolveSubsections({
key: props.activeSection,
schema: activeSectionSchema,
uiHints: props.uiHints,
})
: [];
const allowSubnav =
props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery
? null
: isAllSubsection
? null
: (props.activeSubsection ?? subsections[0]?.key ?? null);
const activeSectionMeta =
props.activeSection && !isVirtualSection
? resolveSectionMeta(props.activeSection, activeSectionSchema)
: null;
// Config subsections are always rendered as a single page per section.
const effectiveSubsection = null;
const topTabs = [
{ key: null as string | null, label: props.navRootLabel ?? "Settings" },
...[...visibleCategories, ...(otherCategory ? [otherCategory] : [])].flatMap((cat) =>
cat.sections.map((s) => ({ key: s.key, label: s.label })),
),
];
// Compute diff for showing changes (works for both form and raw modes)
const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected &&
!props.saving &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
props.connected && !props.saving && hasChanges && (formMode === "raw" ? true : canSaveForm);
const canApply =
props.connected &&
!props.applying &&
!props.updating &&
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
(formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const selectedTags = new Set(getTagFilters(props.searchQuery));
const showAppearanceOnRoot =
includeVirtualSections &&
formMode === "form" &&
props.activeSection === null &&
Boolean(include?.has("__appearance__"));
return html`
<div class="config-layout">
<!-- Sidebar -->
<aside class="config-sidebar">
<div class="config-sidebar__header">
<div class="config-sidebar__title">Settings</div>
<span
class="pill pill--sm ${
validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""
}"
>${validity}</span
>
</div>
<!-- Search -->
<div class="config-search">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
<div class="config-search__hint">
<span class="config-search__hint-label" id="config-tag-filter-label">Tag filters:</span>
<details class="config-search__tag-picker">
<summary class="config-search__tag-trigger" aria-labelledby="config-tag-filter-label">
${
selectedTags.size === 0
? html`
<span class="config-search__tag-placeholder">Add tags</span>
`
: html`
<div class="config-search__tag-chips">
${Array.from(selectedTags)
.slice(0, 2)
.map(
(tag) =>
html`<span class="config-search__tag-chip">tag:${tag}</span>`,
)}
${
selectedTags.size > 2
? html`
<span class="config-search__tag-chip config-search__tag-chip--count"
>+${selectedTags.size - 2}</span
>
`
: nothing
}
</div>
`
}
<span class="config-search__tag-caret" aria-hidden="true">▾</span>
</summary>
<div class="config-search__tag-menu">
${TAG_SEARCH_PRESETS.map((tag) => {
const active = selectedTags.has(tag);
return html`
<button
type="button"
class="config-search__tag-option ${active ? "active" : ""}"
data-tag="${tag}"
aria-pressed=${active ? "true" : "false"}
@click=${() => {
const nextTags = active
? Array.from(selectedTags).filter((value) => value !== tag)
: [...selectedTags, tag];
props.onSearchChange(replaceTagFilters(props.searchQuery, nextTags));
}}
>
tag:${tag}
</button>
`;
})}
</div>
</details>
</div>
</div>
<!-- Section nav -->
<nav class="config-nav">
<button
class="config-nav__item ${props.activeSection === null ? "active" : ""}"
@click=${() => props.onSectionChange(null)}
>
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(
(section) => html`
<button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
>
<span class="config-nav__icon"
>${getSectionIcon(section.key)}</span
>
<span class="config-nav__label">${section.label}</span>
</button>
`,
)}
</nav>
<!-- Mode toggle at bottom -->
<div class="config-sidebar__footer">
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${props.formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${props.formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
</div>
</aside>
<!-- Main content -->
<main class="config-main">
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${
hasChanges
? html`
<span class="config-changes-badge"
>${
props.formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>
`
<span class="config-changes-badge"
>${
formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>
`
: html`
<span class="config-status muted">No changes</span>
`
@@ -664,9 +781,112 @@ export function renderConfig(props: ConfigProps) {
</div>
</div>
<div class="config-top-tabs">
${
formMode === "form"
? html`
<div class="config-search config-search--top">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
`
: nothing
}
<div class="config-top-tabs__scroller" role="tablist" aria-label="Settings sections">
${topTabs.map(
(tab) => html`
<button
class="config-top-tabs__tab ${props.activeSection === tab.key ? "active" : ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
@click=${() => props.onSectionChange(tab.key)}
title=${tab.label}
>
${tab.label}
</button>
`,
)}
</div>
<div class="config-top-tabs__right">
${
showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
`
: nothing
}
</div>
</div>
${
validity === "invalid" && !validityDismissed
? html`
<div class="config-validity-warning">
<svg class="config-validity-warning__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="config-validity-warning__text">Your configuration is invalid. Some settings may not work as expected.</span>
<button
class="btn btn--sm"
@click=${() => {
validityDismissed = true;
props.onRawChange(props.raw);
}}
>Don't remind again</button>
</div>
`
: nothing
}
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${
hasChanges && props.formMode === "form"
hasChanges && formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
@@ -706,12 +926,12 @@ export function renderConfig(props: ConfigProps) {
`
: nothing
}
${
activeSectionMeta && props.formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
${
activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">
@@ -725,43 +945,46 @@ export function renderConfig(props: ConfigProps) {
: nothing
}
</div>
${
props.activeSection === "env"
? html`
<button
class="config-env-peek-btn"
title="Toggle value visibility"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const content = btn
.closest(".config-main")
?.querySelector(".config-content");
if (content) {
content.classList.toggle("config-env-values--visible");
}
btn.classList.toggle("config-env-peek-btn--active");
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Peek
</button>
`
: nothing
}
</div>
`
: nothing
}
${
allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@click=${() => props.onSubsectionChange(ALL_SUBSECTION)}
>
All
</button>
${subsections.map(
(entry) => html`
<button
class="config-subnav__item ${
effectiveSubsection === entry.key ? "active" : ""
}"
title=${entry.description || entry.label}
@click=${() => props.onSubsectionChange(entry.key)}
>
${entry.label}
</button>
`,
)}
</div>
`
: nothing
}
: nothing
}
<!-- Form content -->
<div class="config-content">
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}">
${
props.formMode === "form"
? html`
props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${
props.schemaLoading
? html`
@@ -792,16 +1015,43 @@ export function renderConfig(props: ConfigProps) {
: nothing
}
`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`
: (() => {
const sensitiveCount = countSensitiveValues(props.formValue);
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
return html`
<label class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw JSON5
${
sensitiveCount > 0
? html`
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
<button
class="btn btn--icon ${blurred ? "" : "active"}"
style="width:28px;height:28px;padding:0;"
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
rawRevealed = !rawRevealed;
props.onRawChange(props.raw);
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`;
})()
}
</div>

View File

@@ -33,7 +33,7 @@ export function renderDebug(props: DebugProps) {
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
return html`
<section class="grid grid-cols-2">
<section class="grid">
<div class="card">
<div class="row" style="justify-content: space-between;">
<div>

View File

@@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts";
import { icons } from "../icons.ts";
import { formatPresenceAge } from "../presenter.ts";
import type { PresenceEntry } from "../types.ts";
export type InstancesProps = {
@@ -7,10 +8,15 @@ export type InstancesProps = {
entries: PresenceEntry[];
lastError: string | null;
statusMessage: string | null;
streamMode: boolean;
onRefresh: () => void;
};
let hostsRevealed = false;
export function renderInstances(props: InstancesProps) {
const masked = props.streamMode || !hostsRevealed;
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
@@ -18,9 +24,24 @@ export function renderInstances(props: InstancesProps) {
<div class="card-title">Connected Instances</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
<div class="row" style="gap: 8px;">
<button
class="btn btn--icon ${masked ? "" : "active"}"
@click=${() => {
hostsRevealed = !hostsRevealed;
props.onRefresh();
}}
title=${masked ? "Show hosts and IPs" : "Hide hosts and IPs"}
aria-label="Toggle host visibility"
aria-pressed=${!masked}
style="width: 36px; height: 36px;"
>
${masked ? icons.eyeOff : icons.eye}
</button>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div>
${
props.lastError
@@ -42,16 +63,18 @@ export function renderInstances(props: InstancesProps) {
? html`
<div class="muted">No instances reported yet.</div>
`
: props.entries.map((entry) => renderEntry(entry))
: props.entries.map((entry) => renderEntry(entry, masked))
}
</div>
</section>
`;
}
function renderEntry(entry: PresenceEntry) {
function renderEntry(entry: PresenceEntry, masked: boolean) {
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
const mode = entry.mode ?? "unknown";
const host = entry.host ?? "unknown host";
const ip = entry.ip ?? null;
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel =
@@ -63,8 +86,12 @@ function renderEntry(entry: PresenceEntry) {
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.host ?? "unknown host"}</div>
<div class="list-sub">${formatPresenceSummary(entry)}</div>
<div class="list-title">
<span class="${masked ? "redacted" : ""}">${host}</span>
</div>
<div class="list-sub">
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
</div>
<div class="chip-row">
<span class="chip">${mode}</span>
${roles.map((role) => html`<span class="chip">${role}</span>`)}

View File

@@ -0,0 +1,132 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import { renderThemeToggle } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import { icons } from "../icons.ts";
import { normalizeBasePath } from "../navigation.ts";
export function renderLoginGate(state: AppViewState) {
const basePath = normalizeBasePath(state.basePath ?? "");
const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg";
return html`
<div class="login-gate">
<div class="login-gate__theme">${renderThemeToggle(state)}</div>
<div class="login-gate__card">
<div class="login-gate__header">
<img class="login-gate__logo" src=${faviconSrc} alt="OpenClaw" />
<div class="login-gate__title">OpenClaw</div>
<div class="login-gate__sub">${t("login.subtitle")}</div>
</div>
<div class="login-gate__form">
<label class="field">
<span>${t("overview.access.wsUrl")}</span>
<input
.value=${state.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.applySettings({ ...state.settings, gatewayUrl: v });
}}
placeholder="ws://127.0.0.1:18789"
/>
</label>
<label class="field">
<span>${t("overview.access.token")}</span>
<div class="login-gate__secret-row">
<input
type=${state.loginShowGatewayToken ? "text" : "password"}
autocomplete="off"
spellcheck="false"
.value=${state.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.applySettings({ ...state.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN (${t("login.passwordPlaceholder")})"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
state.connect();
}
}}
/>
<button
type="button"
class="btn btn--icon ${state.loginShowGatewayToken ? "active" : ""}"
title=${state.loginShowGatewayToken ? "Hide token" : "Show token"}
aria-label="Toggle token visibility"
aria-pressed=${state.loginShowGatewayToken}
@click=${() => {
state.loginShowGatewayToken = !state.loginShowGatewayToken;
}}
>
${state.loginShowGatewayToken ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<label class="field">
<span>${t("overview.access.password")}</span>
<div class="login-gate__secret-row">
<input
type=${state.loginShowGatewayPassword ? "text" : "password"}
autocomplete="off"
spellcheck="false"
.value=${state.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.password = v;
}}
placeholder="${t("login.passwordPlaceholder")}"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
state.connect();
}
}}
/>
<button
type="button"
class="btn btn--icon ${state.loginShowGatewayPassword ? "active" : ""}"
title=${state.loginShowGatewayPassword ? "Hide password" : "Show password"}
aria-label="Toggle password visibility"
aria-pressed=${state.loginShowGatewayPassword}
@click=${() => {
state.loginShowGatewayPassword = !state.loginShowGatewayPassword;
}}
>
${state.loginShowGatewayPassword ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<button
class="btn primary login-gate__connect"
@click=${() => state.connect()}
>
${t("common.connect")}
</button>
</div>
${
state.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${state.lastError}</div>
</div>`
: ""
}
<div class="login-gate__help">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
</ol>
<div class="login-gate__docs">
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,60 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons, type IconName } from "../icons.ts";
import type { AttentionItem } from "../types.ts";
export type OverviewAttentionProps = {
items: AttentionItem[];
};
function severityClass(severity: string) {
if (severity === "error") {
return "danger";
}
if (severity === "warning") {
return "warn";
}
return "";
}
function attentionIcon(name: string) {
if (name in icons) {
return icons[name as IconName];
}
return icons.radio;
}
export function renderOverviewAttention(props: OverviewAttentionProps) {
if (props.items.length === 0) {
return nothing;
}
return html`
<section class="card ov-attention">
<div class="card-title">${t("overview.attention.title")}</div>
<div class="ov-attention-list">
${props.items.map(
(item) => html`
<div class="ov-attention-item ${severityClass(item.severity)}">
<span class="ov-attention-icon">${attentionIcon(item.icon)}</span>
<div class="ov-attention-body">
<div class="ov-attention-title">${item.title}</div>
<div class="muted">${item.description}</div>
</div>
${
item.href
? html`<a
class="ov-attention-link"
href=${item.href}
target=${item.external ? "_blank" : ""}
rel=${item.external ? "noreferrer" : ""}
>${t("common.docs")}</a>`
: nothing
}
</div>
`,
)}
</div>
</section>
`;
}

View File

@@ -0,0 +1,147 @@
import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { t } from "../../i18n/index.ts";
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
import { formatNextRun } from "../presenter.ts";
import type {
SessionsUsageResult,
SessionsListResult,
SkillStatusReport,
CronJob,
CronStatus,
} from "../types.ts";
export type OverviewCardsProps = {
usageResult: SessionsUsageResult | null;
sessionsResult: SessionsListResult | null;
skillsReport: SkillStatusReport | null;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
presenceCount: number;
redacted: boolean;
onNavigate: (tab: string) => void;
};
function redact(value: string, redacted: boolean) {
return redacted ? "••••••" : value;
}
const DIGIT_RUN = /\d{3,}/g;
function blurDigits(value: string): TemplateResult {
const escaped = value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const blurred = escaped.replace(DIGIT_RUN, (m) => `<span class="blur-digits">${m}</span>`);
return html`${unsafeHTML(blurred)}`;
}
type StatCard = {
kind: string;
tab: string;
label: string;
value: string | TemplateResult;
hint: string | TemplateResult;
redacted?: boolean;
};
function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
return html`
<button class="ov-card" data-kind=${card.kind} @click=${() => onNavigate(card.tab)}>
<span class="ov-card__label">${card.label}</span>
<span class="ov-card__value ${card.redacted ? "redacted" : ""}">${card.value}</span>
<span class="ov-card__hint">${card.hint}</span>
</button>
`;
}
export function renderOverviewCards(props: OverviewCardsProps) {
const totals = props.usageResult?.totals;
const totalCost = formatCost(totals?.totalCost);
const totalTokens = formatTokens(totals?.totalTokens);
const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0";
const sessionCount = props.sessionsResult?.count ?? null;
const skills = props.skillsReport?.skills ?? [];
const enabledSkills = skills.filter((s) => !s.disabled).length;
const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length;
const totalSkills = skills.length;
const cronEnabled = props.cronStatus?.enabled ?? null;
const cronNext = props.cronStatus?.nextWakeAtMs ?? null;
const cronJobCount = props.cronJobs.length;
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length;
const cronValue =
cronEnabled == null
? t("common.na")
: cronEnabled
? `${cronJobCount} jobs`
: t("common.disabled");
const cronHint =
failedCronCount > 0
? html`<span class="danger">${failedCronCount} failed</span>`
: cronNext
? t("overview.stats.cronNext", { time: formatNextRun(cronNext) })
: "";
const cards: StatCard[] = [
{
kind: "cost",
tab: "usage",
label: t("overview.cards.cost"),
value: redact(totalCost, props.redacted),
hint: redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted),
redacted: props.redacted,
},
{
kind: "sessions",
tab: "sessions",
label: t("overview.stats.sessions"),
value: String(sessionCount ?? t("common.na")),
hint: t("overview.stats.sessionsHint"),
},
{
kind: "skills",
tab: "skills",
label: t("overview.cards.skills"),
value: `${enabledSkills}/${totalSkills}`,
hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`,
},
{
kind: "cron",
tab: "cron",
label: t("overview.stats.cron"),
value: cronValue,
hint: cronHint,
},
];
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
return html`
<section class="ov-cards">
${cards.map((c) => renderStatCard(c, props.onNavigate))}
</section>
${
sessions.length > 0
? html`
<section class="ov-recent">
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
<ul class="ov-recent__list">
${sessions.map(
(s) => html`
<li class="ov-recent__row ${props.redacted ? "redacted" : ""}">
<span class="ov-recent__key">${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)}</span>
<span class="ov-recent__model">${s.model ?? ""}</span>
<span class="ov-recent__time">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
</li>
`,
)}
</ul>
</section>
`
: nothing
}
`;
}

View File

@@ -0,0 +1,43 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { icons } from "../icons.ts";
import { formatEventPayload } from "../presenter.ts";
export type OverviewEventLogProps = {
events: EventLogEntry[];
redacted: boolean;
};
export function renderOverviewEventLog(props: OverviewEventLogProps) {
if (props.events.length === 0) {
return nothing;
}
const visible = props.events.slice(0, 20);
return html`
<details class="card ov-event-log">
<summary class="ov-expandable-toggle">
<span class="nav-item__icon">${icons.radio}</span>
${t("overview.eventLog.title")}
<span class="ov-count-badge">${props.events.length}</span>
</summary>
<div class="ov-event-log-list ${props.redacted ? "redacted" : ""}">
${visible.map(
(entry) => html`
<div class="ov-event-log-entry">
<span class="ov-event-log-ts">${new Date(entry.ts).toLocaleTimeString()}</span>
<span class="ov-event-log-name">${entry.event}</span>
${
entry.payload
? html`<span class="ov-event-log-payload muted">${formatEventPayload(entry.payload).slice(0, 120)}</span>`
: nothing
}
</div>
`,
)}
</div>
</details>
`;
}

View File

@@ -0,0 +1,47 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */
function stripAnsi(text: string): string {
/* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */
return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
}
export type OverviewLogTailProps = {
lines: string[];
redacted: boolean;
onRefreshLogs: () => void;
};
export function renderOverviewLogTail(props: OverviewLogTailProps) {
if (props.lines.length === 0) {
return nothing;
}
const displayLines = props.redacted
? "[log hidden]"
: props.lines
.slice(-50)
.map((line) => stripAnsi(line))
.join("\n");
return html`
<details class="card ov-log-tail">
<summary class="ov-expandable-toggle">
<span class="nav-item__icon">${icons.scrollText}</span>
${t("overview.logTail.title")}
<span class="ov-count-badge">${props.lines.length}</span>
<span
class="ov-log-refresh"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
props.onRefreshLogs();
}}
>${icons.loader}</span>
</summary>
<pre class="ov-log-tail-content ${props.redacted ? "redacted" : ""}">${displayLines}</pre>
</details>
`;
}

View File

@@ -0,0 +1,31 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
export type OverviewQuickActionsProps = {
onNavigate: (tab: string) => void;
onRefresh: () => void;
};
export function renderOverviewQuickActions(props: OverviewQuickActionsProps) {
return html`
<section class="ov-quick-actions">
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("chat")}>
<span class="nav-item__icon">${icons.messageSquare}</span>
${t("overview.quickActions.newSession")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("cron")}>
<span class="nav-item__icon">${icons.zap}</span>
${t("overview.quickActions.automation")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onRefresh()}>
<span class="nav-item__icon">${icons.loader}</span>
${t("overview.quickActions.refreshAll")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("sessions")}>
<span class="nav-item__icon">${icons.monitor}</span>
${t("overview.quickActions.terminal")}
</button>
</section>
`;
}

View File

@@ -1,12 +1,25 @@
import { html } from "lit";
import { html, nothing } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
import { formatNextRun } from "../presenter.ts";
import { icons } from "../icons.ts";
import type { UiSettings } from "../storage.ts";
import type {
AttentionItem,
CronJob,
CronStatus,
SessionsListResult,
SessionsUsageResult,
SkillStatusReport,
} from "../types.ts";
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
connected: boolean;
@@ -14,36 +27,52 @@ export type OverviewProps = {
settings: UiSettings;
password: string;
lastError: string | null;
lastErrorCode: string | null;
presenceCount: number;
sessionsCount: number | null;
cronEnabled: boolean | null;
cronNext: number | null;
lastChannelsRefresh: number | null;
// New dashboard data
usageResult: SessionsUsageResult | null;
sessionsResult: SessionsListResult | null;
skillsReport: SkillStatusReport | null;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
attentionItems: AttentionItem[];
eventLog: EventLogEntry[];
overviewLogLines: string[];
streamMode: boolean;
showGatewayToken: boolean;
showGatewayPassword: boolean;
onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void;
onToggleGatewayTokenVisibility: () => void;
onToggleGatewayPasswordVisibility: () => void;
onConnect: () => void;
onRefresh: () => void;
onNavigate: (tab: string) => void;
onRefreshLogs: () => void;
onToggleStreamMode: () => void;
};
export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as
| {
uptimeMs?: number;
policy?: { tickIntervalMs?: number };
authMode?: "none" | "token" | "password" | "trusted-proxy";
}
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs
? `${snapshot.policy.tickIntervalMs}ms`
const tickIntervalMs = props.hello?.policy?.tickIntervalMs;
const tick = tickIntervalMs
? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
: t("common.na");
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
const pairingHint = (() => {
if (!shouldShowPairingHint(props.connected, props.lastError, props.lastErrorCode)) {
if (!shouldShowPairingHint(props.connected, props.lastError)) {
return null;
}
return html`
@@ -75,37 +104,13 @@ export function renderOverview(props: OverviewProps) {
return null;
}
const lower = props.lastError.toLowerCase();
const authRequiredCodes = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const authFailureCodes = new Set<string>([
...authRequiredCodes,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const authFailed = props.lastErrorCode
? authFailureCodes.has(props.lastErrorCode)
: lower.includes("unauthorized") || lower.includes("connect failed");
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
return null;
}
const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim());
const isAuthRequired = props.lastErrorCode
? authRequiredCodes.has(props.lastErrorCode)
: !hasToken && !hasPassword;
if (isAuthRequired) {
if (!hasToken && !hasPassword) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}
@@ -152,14 +157,7 @@ export function renderOverview(props: OverviewProps) {
return null;
}
const lower = props.lastError.toLowerCase();
const insecureContextCode =
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
if (
!insecureContextCode &&
!lower.includes("secure context") &&
!lower.includes("device identity required")
) {
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;
}
return html`
@@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) {
const currentLocale = i18n.getLocale();
return html`
<section class="grid grid-cols-2">
<section class="grid">
<div class="card">
<div class="card-title">${t("overview.access.title")}</div>
<div class="card-sub">${t("overview.access.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<div class="ov-access-grid ${props.streamMode ? "redacted" : ""}" style="margin-top: 16px;">
<label class="field ov-access-grid__full">
<span>${t("overview.access.wsUrl")}</span>
<input
.value=${props.settings.gatewayUrl}
@@ -216,26 +214,57 @@ export function renderOverview(props: OverviewProps) {
: html`
<label class="field">
<span>${t("overview.access.token")}</span>
<input
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
<div style="display: flex; align-items: center; gap: 8px;">
<input
type=${props.showGatewayToken ? "text" : "password"}
autocomplete="off"
style="flex: 1;"
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayToken ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayToken ? "Hide token" : "Show token"}
aria-label="Toggle token visibility"
aria-pressed=${props.showGatewayToken}
@click=${props.onToggleGatewayTokenVisibility}
>
${props.showGatewayToken ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<label class="field">
<span>${t("overview.access.password")}</span>
<input
type="password"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
<div style="display: flex; align-items: center; gap: 8px;">
<input
type=${props.showGatewayPassword ? "text" : "password"}
autocomplete="off"
style="flex: 1;"
.value=${props.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayPassword ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayPassword ? "Hide password" : "Show password"}
aria-label="Toggle password visibility"
aria-pressed=${props.showGatewayPassword}
@click=${props.onToggleGatewayPasswordVisibility}
>
${props.showGatewayPassword ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
`
}
@@ -273,6 +302,30 @@ export function renderOverview(props: OverviewProps) {
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span>
</div>
${
!props.connected
? html`
<div class="login-gate__help" style="margin-top: 16px;">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
</ol>
<div class="login-gate__docs">
${t("overview.connection.docsHint")}
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
</div>
`
: nothing
}
</div>
<div class="card">
@@ -317,45 +370,47 @@ export function renderOverview(props: OverviewProps) {
</div>
</section>
<section class="grid grid-cols-3" style="margin-top: 18px;">
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.instances")}</div>
<div class="stat-value">${props.presenceCount}</div>
<div class="muted">${t("overview.stats.instancesHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.sessions")}</div>
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
<div class="muted">${t("overview.stats.sessionsHint")}</div>
</div>
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.cron")}</div>
<div class="stat-value">
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
</div>
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
</div>
</section>
${
props.streamMode
? html`<div class="callout ov-stream-banner" style="margin-top: 18px;">
<span class="nav-item__icon">${icons.radio}</span>
${t("overview.streamMode.active")}
<button class="btn btn--sm" style="margin-left: auto;" @click=${() => props.onToggleStreamMode()}>
${t("overview.streamMode.disable")}
</button>
</div>`
: nothing
}
<div class="ov-section-divider"></div>
${renderOverviewCards({
usageResult: props.usageResult,
sessionsResult: props.sessionsResult,
skillsReport: props.skillsReport,
cronJobs: props.cronJobs,
cronStatus: props.cronStatus,
presenceCount: props.presenceCount,
redacted: props.streamMode,
onNavigate: props.onNavigate,
})}
${renderOverviewAttention({ items: props.attentionItems })}
<div class="ov-section-divider"></div>
<div class="ov-bottom-grid" style="margin-top: 18px;">
${renderOverviewEventLog({
events: props.eventLog,
redacted: props.streamMode,
})}
${renderOverviewLogTail({
lines: props.overviewLogLines,
redacted: props.streamMode,
onRefreshLogs: props.onRefreshLogs,
})}
</div>
<section class="card" style="margin-top: 18px;">
<div class="card-title">${t("overview.notes.title")}</div>
<div class="card-sub">${t("overview.notes.subtitle")}</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">${t("overview.notes.tailscaleTitle")}</div>
<div class="muted">
${t("overview.notes.tailscaleText")}
</div>
</div>
<div>
<div class="note-title">${t("overview.notes.sessionTitle")}</div>
<div class="muted">${t("overview.notes.sessionText")}</div>
</div>
<div>
<div class="note-title">${t("overview.notes.cronTitle")}</div>
<div class="muted">${t("overview.notes.cronText")}</div>
</div>
</div>
</section>
`;
}

View File

@@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
@@ -13,12 +14,23 @@ export type SessionsProps = {
includeGlobal: boolean;
includeUnknown: boolean;
basePath: string;
searchQuery: string;
sortColumn: "key" | "kind" | "updated" | "tokens";
sortDir: "asc" | "desc";
page: number;
pageSize: number;
actionsOpenKey: string | null;
onFiltersChange: (next: {
activeMinutes: string;
limit: string;
includeGlobal: boolean;
includeUnknown: boolean;
}) => void;
onSearchChange: (query: string) => void;
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onActionsOpenChange: (key: string | null) => void;
onRefresh: () => void;
onPatch: (
key: string,
@@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [
{ value: "full", label: "full" },
] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
const PAGE_SIZES = [10, 25, 50, 100] as const;
function normalizeProviderId(provider?: string | null): string {
if (!provider) {
@@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
return value;
}
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
const q = query.trim().toLowerCase();
if (!q) {
return rows;
}
return rows.filter((row) => {
const key = (row.key ?? "").toLowerCase();
const label = (row.label ?? "").toLowerCase();
const kind = (row.kind ?? "").toLowerCase();
const displayName = (row.displayName ?? "").toLowerCase();
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
});
}
function sortRows(
rows: GatewaySessionRow[],
column: "key" | "kind" | "updated" | "tokens",
dir: "asc" | "desc",
): GatewaySessionRow[] {
const cmp = dir === "asc" ? 1 : -1;
return [...rows].toSorted((a, b) => {
let diff = 0;
switch (column) {
case "key":
diff = (a.key ?? "").localeCompare(b.key ?? "");
break;
case "kind":
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
break;
case "updated": {
const au = a.updatedAt ?? 0;
const bu = b.updatedAt ?? 0;
diff = au - bu;
break;
}
case "tokens": {
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
diff = at - bt;
break;
}
}
return diff * cmp;
});
}
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
const start = page * pageSize;
return rows.slice(start, start + pageSize);
}
export function renderSessions(props: SessionsProps) {
const rows = props.result?.sessions ?? [];
const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
const totalRows = sorted.length;
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
const page = Math.min(props.page, totalPages - 1);
const paginated = paginateRows(sorted, page, props.pageSize);
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
const isActive = props.sortColumn === col;
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
return html`
<th
data-sortable
data-sort-dir=${isActive ? props.sortDir : ""}
@click=${() => props.onSortChange(col, isActive ? nextDir : "desc")}
>
${label}
<span class="data-table-sort-icon">${icons.arrowUpDown}</span>
</th>
`;
};
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
${
props.actionsOpenKey
? html`
<div
class="data-table-overlay"
@click=${() => props.onActionsOpenChange(null)}
aria-hidden="true"
></div>
`
: nothing
}
<section class="card" style=${props.actionsOpenKey ? "position: relative; z-index: 41;" : ""}>
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div>
<div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field">
<span>Active within (minutes)</span>
<div class="filters" style="margin-bottom: 12px;">
<label class="field-inline">
<span>Active</span>
<input
style="width: 72px;"
placeholder="min"
.value=${props.activeMinutes}
@input=${(e: Event) =>
props.onFiltersChange({
@@ -135,9 +234,10 @@ export function renderSessions(props: SessionsProps) {
})}
/>
</label>
<label class="field">
<label class="field-inline">
<span>Limit</span>
<input
style="width: 64px;"
.value=${props.limit}
@input=${(e: Event) =>
props.onFiltersChange({
@@ -148,8 +248,7 @@ export function renderSessions(props: SessionsProps) {
})}
/>
</label>
<label class="field checkbox">
<span>Include global</span>
<label class="field-inline checkbox">
<input
type="checkbox"
.checked=${props.includeGlobal}
@@ -161,9 +260,9 @@ export function renderSessions(props: SessionsProps) {
includeUnknown: props.includeUnknown,
})}
/>
<span>Global</span>
</label>
<label class="field checkbox">
<span>Include unknown</span>
<label class="field-inline checkbox">
<input
type="checkbox"
.checked=${props.includeUnknown}
@@ -175,39 +274,102 @@ export function renderSessions(props: SessionsProps) {
includeUnknown: (e.target as HTMLInputElement).checked,
})}
/>
<span>Unknown</span>
</label>
</div>
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
: nothing
}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
</div>
<div class="table" style="margin-top: 16px;">
<div class="table-head">
<div>Key</div>
<div>Label</div>
<div>Kind</div>
<div>Updated</div>
<div>Tokens</div>
<div>Thinking</div>
<div>Verbose</div>
<div>Reasoning</div>
<div>Actions</div>
<div class="data-table-wrapper">
<div class="data-table-toolbar">
<div class="data-table-search">
<input
type="text"
placeholder="Filter by key, label, kind…"
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
</div>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
${sortHeader("key", "Key")}
<th>Label</th>
${sortHeader("kind", "Kind")}
${sortHeader("updated", "Updated")}
${sortHeader("tokens", "Tokens")}
<th>Thinking</th>
<th>Verbose</th>
<th>Reasoning</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody>
${
paginated.length === 0
? html`
<tr>
<td colspan="9" style="text-align: center; padding: 48px 16px; color: var(--muted)">
No sessions found.
</td>
</tr>
`
: paginated.map((row) =>
renderRow(
row,
props.basePath,
props.onPatch,
props.onDelete,
props.onActionsOpenChange,
props.actionsOpenKey,
props.loading,
),
)
}
</tbody>
</table>
</div>
${
rows.length === 0
totalRows > 0
? html`
<div class="muted">No sessions found.</div>
<div class="data-table-pagination">
<div class="data-table-pagination__info">
${page * props.pageSize + 1}-${Math.min((page + 1) * props.pageSize, totalRows)}
of ${totalRows} row${totalRows === 1 ? "" : "s"}
</div>
<div class="data-table-pagination__controls">
<select
style="height: 32px; padding: 0 8px; font-size: 13px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card);"
.value=${String(props.pageSize)}
@change=${(e: Event) =>
props.onPageSizeChange(Number((e.target as HTMLSelectElement).value))}
>
${PAGE_SIZES.map((s) => html`<option value=${s}>${s} per page</option>`)}
</select>
<button
?disabled=${page <= 0}
@click=${() => props.onPageChange(page - 1)}
>
Previous
</button>
<button
?disabled=${page >= totalPages - 1}
@click=${() => props.onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)
: nothing
}
</div>
</section>
@@ -219,6 +381,8 @@ function renderRow(
basePath: string,
onPatch: SessionsProps["onPatch"],
onDelete: SessionsProps["onDelete"],
onActionsOpenChange: (key: string | null) => void,
actionsOpenKey: string | null,
disabled: boolean,
) {
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
@@ -234,36 +398,58 @@ function renderRow(
typeof row.displayName === "string" && row.displayName.trim().length > 0
? row.displayName.trim()
: null;
const label = typeof row.label === "string" ? row.label.trim() : "";
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label);
const showDisplayName = Boolean(
displayName &&
displayName !== row.key &&
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
);
const canLink = row.kind !== "global";
const chatUrl = canLink
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
: null;
const isMenuOpen = actionsOpenKey === row.key;
const badgeClass =
row.kind === "direct"
? "data-table-badge--direct"
: row.kind === "group"
? "data-table-badge--group"
: row.kind === "global"
? "data-table-badge--global"
: "data-table-badge--unknown";
return html`
<div class="table-row">
<div class="mono session-key-cell">
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
${showDisplayName ? html`<span class="muted session-key-display-name">${displayName}</span>` : nothing}
</div>
<div>
<tr>
<td>
<div class="mono session-key-cell">
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
${
showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing
}
</div>
</td>
<td>
<input
.value=${row.label ?? ""}
?disabled=${disabled}
placeholder="(optional)"
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
@change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null });
}}
/>
</div>
<div>${row.kind}</div>
<div>${updated}</div>
<div>${formatSessionTokens(row)}</div>
<div>
</td>
<td>
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
</td>
<td>${updated}</td>
<td>${formatSessionTokens(row)}</td>
<td>
<select
?disabled=${disabled}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, {
@@ -278,10 +464,11 @@ function renderRow(
</option>`,
)}
</select>
</div>
<div>
</td>
<td>
<select
?disabled=${disabled}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null });
@@ -294,10 +481,11 @@ function renderRow(
</option>`,
)}
</select>
</div>
<div>
</td>
<td>
<select
?disabled=${disabled}
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
@change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null });
@@ -310,12 +498,53 @@ function renderRow(
</option>`,
)}
</select>
</div>
<div>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
Delete
</button>
</div>
</div>
</td>
<td>
<div class="data-table-row-actions">
<button
type="button"
class="data-table-row-actions__trigger"
aria-label="Open menu"
@click=${(e: Event) => {
e.stopPropagation();
onActionsOpenChange(isMenuOpen ? null : row.key);
}}
>
${icons.moreHorizontal}
</button>
${
isMenuOpen
? html`
<div class="data-table-row-actions__menu">
${
canLink
? html`
<a
href=${chatUrl}
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
@click=${() => onActionsOpenChange(null)}
>
Open in Chat
</a>
`
: nothing
}
<button
type="button"
class="danger"
@click=${() => {
onActionsOpenChange(null);
onDelete(row.key);
}}
>
Delete
</button>
</div>
`
: nothing
}
</div>
</td>
</tr>
`;
}

View File

@@ -40,16 +40,22 @@ export function renderSkills(props: SkillsProps) {
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Skills</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div>
<div class="card-sub">Installed skills and their status.</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
<span>Filter</span>
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
<a
class="btn"
href="https://clawhub.com"
target="_blank"
rel="noreferrer"
title="Browse skills on ClawHub"
>Browse Skills Store</a>
<label class="field" style="flex: 1; min-width: 180px;">
<input
.value=${props.filter}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}

View File

@@ -39,5 +39,23 @@ export default defineConfig(() => {
port: 5173,
strictPort: true,
},
plugins: [
{
name: "control-ui-dev-stubs",
configureServer(server) {
server.middlewares.use("/__openclaw/control-ui-config.json", (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
basePath: "/",
assistantName: "",
assistantAvatar: "",
assistantAgentId: "",
}),
);
});
},
},
],
};
});