Compare commits

...

10 Commits

Author SHA1 Message Date
Val Alexander
441426af87
fix: wire handleChatGatewayEvent to replace inline chat handler 2026-03-09 17:39:30 -05:00
Val Alexander
df82c4998d
fix(ui): address 4 review comments on dashboard-v2
- Reset chat module state on tab navigation (stops STT leak)
- Extract exportChatMarkdown to shared helper (deduplicate)
- Move theme listener cleanup to host instance (fix stale ref)
- Load cron runs on initial Cron tab open
2026-03-09 17:32:00 -05:00
Val Alexander
43430d4900
fix(ui): resolve remaining important and minor issues
4. fix(theme): prevent stale theme listener after component remount
   - Replace module-level systemThemeCleanup with WeakMap keyed by host
   - Prevents stale closure responding to theme changes after remount
   - Addresses Greptile test/HMR issue

5. fix(security): validate usage/cost metadata from chat history
   - Add sanitizeUsage() and sanitizeCost() helpers
   - Validate numeric fields are finite numbers
   - Only allow usage/cost on assistant messages
   - Prevents UI crash from malformed transcript JSON (cost.toFixed on non-number)
   - Addresses Aisle Security Low severity (but UI-breaking) issue

6. refactor(chat): deduplicate export functions
   - Extract exportChatMarkdown to shared chat-export.ts module
   - Remove duplicate from app.ts and chat.ts
   - Prevents silent divergence during maintenance
   - Addresses Greptile technical debt concern

7. fix(security): add noopener to external links
   - Use buildExternalLinkRel() helper in overview-attention.ts
   - Prevents reverse tabnabbing on attention item doc links
   - Addresses Aisle Security Low severity CWE-1022

8. fix(security): scan raw config for sensitive keywords in stream mode
   - Add containsSensitiveKeywords() helper
   - Check props.raw for token/password/secret/apiKey patterns
   - Redact raw textarea when keywords detected in stream mode
   - Prevents newly-entered secrets from staying visible before parse
   - Addresses Aisle Security Low severity issue
2026-03-06 03:29:45 -06:00
Val Alexander
03aa0f969c
Merge branch 'main' into dashboard-v2-structure 2026-03-06 03:22:15 -06:00
Val Alexander
f4f8eac3a3
fix(ui): resolve 3 critical security and UX issues
1. fix(security): prevent JSON DoS via size cap on auto-parse
   - Add MAX_JSON_AUTOPARSE_CHARS (20KB) to detectJson()
   - Prevents UI freeze from multi-MB JSON in assistant/tool messages
   - Addresses Aisle Security High severity CWE-400

2. fix(ux): prevent STT transcripts going to wrong session
   - Add cleanupChatModuleState() export in chat.ts
   - Call cleanup in applyTabSelection when leaving chat tab
   - Stops active recording to prevent voice input to unintended session
   - Addresses Greptile critical UX bug

3. fix(security): redact sensitive values in config diff panel
   - Add renderDiffValue() with stream-mode + sensitive-path checks
   - Use in diff panel rendering instead of raw truncateValue()
   - Prevents secrets from appearing during screen sharing
   - Addresses Aisle Security Medium severity CWE-200
2026-03-06 00:47:59 -06:00
Val Alexander
39020f8d62
feat: enhance sensitive data handling in config forms
- Updated config form tests to ensure sensitive values are properly managed and revealed based on user interactions.
- Refactored sensitive input rendering logic to support toggling visibility and redaction based on stream mode.
- Improved state management for sensitive paths, allowing for better control over when sensitive data is displayed.
- Added utility functions to identify and handle sensitive configuration data throughout the application.
- Enhanced UI components to reflect changes in sensitive data handling, ensuring a consistent user experience.
2026-03-05 18:25:14 -06:00
Val Alexander
1e440712fb
feat: enhance chat history and error handling in UI
- Added a test to ensure chat.history preserves usage and cost metadata for assistant messages.
- Updated chat message sanitization to retain usage and cost information for UI rendering.
- Enhanced the AppViewState and UI components to include lastErrorCode for improved error handling.
- Implemented new utility functions in overview hints to manage authentication and context errors.
- Updated tests to cover new functionality and ensure correct behavior in various scenarios.
2026-03-05 18:15:41 -06:00
Val Alexander
cfec9a268a
feat: integrate tools catalog functionality into agent management
- Added support for loading and displaying a tools catalog in the agent management interface.
- Enhanced the AppViewState to include loading, error, and result states for the tools catalog.
- Implemented loadToolsCatalog function to fetch tools based on the selected agent.
- Updated UI components to reflect tools catalog loading states and errors.
- Refactored agent tools rendering logic to utilize the new tools catalog data structure.
2026-03-05 17:57:47 -06:00
Val Alexander
58c96468cf
feat: implement /kill command for managing sub-agent sessions
- Enhanced the executeSlashCommand function to support the /kill command, allowing users to abort sub-agent sessions.
- Added logic to handle both "kill all" and "kill <agentId>" scenarios, providing appropriate feedback based on the number of sessions aborted.
- Introduced a new utility function, resolveKillTargets, to identify matching sub-agent sessions based on the provided target.
- Added unit tests for the /kill command to ensure correct functionality and response messages.
2026-03-05 17:48:29 -06:00
Val Alexander
1f1f444aa1
ui: refactor dashboard-v2 structure and behavior 2026-03-05 17:24:37 -06:00
71 changed files with 8745 additions and 1738 deletions

View File

@ -0,0 +1,96 @@
---
name: merge-main-dashboard-v2
overview: Update `ui/dashboard-v2` with the latest `main`, resolve conflicts in a deterministic order, and keep one clean PR while documenting logical sub-sections for easier review and optional follow-up splits.
todos:
- id: sync-origin-main
content: Fetch and verify latest `origin/main` before attempting merge
status: completed
- id: merge-main-into-branch
content: Merge `origin/main` into `ui/dashboard-v2` and stop at conflicts
status: completed
- id: resolve-high-risk-conflicts
content: Resolve cron/render/state/style/i18n conflict hotspots in tiered order
status: completed
- id: validate-post-merge
content: Run tests, lint/check, and build; fix only merge regressions
status: completed
- id: prepare-single-pr-structure
content: Keep one PR but organize description/commit narrative by logical sections
status: completed
isProject: false
---
# Merge Main Into Dashboard V2 Cleanly
## Current State (from local analysis)
- Branch `ui/dashboard-v2` is `3` commits ahead of its base and roughly `2162` commits behind local `main`.
- Branch scope is large (`90` files, heavy UI + CSS + tests).
- Highest conflict-risk overlap with `main` is concentrated in:
- Cron and scheduler UI/controller: [ui/src/ui/views/cron.ts](ui/src/ui/views/cron.ts), [ui/src/ui/controllers/cron.ts](ui/src/ui/controllers/cron.ts), [ui/src/ui/views/cron.test.ts](ui/src/ui/views/cron.test.ts), [ui/src/ui/controllers/cron.test.ts](ui/src/ui/controllers/cron.test.ts)
- Rendering/state and shared UI types: [ui/src/ui/app-render.ts](ui/src/ui/app-render.ts), [ui/src/ui/app-render.helpers.ts](ui/src/ui/app-render.helpers.ts), [ui/src/ui/app-view-state.ts](ui/src/ui/app-view-state.ts), [ui/src/ui/types.ts](ui/src/ui/types.ts)
- Global styling: [ui/src/styles/components.css](ui/src/styles/components.css), [ui/src/styles/config.css](ui/src/styles/config.css), [ui/src/styles/base.css](ui/src/styles/base.css)
- Locales and gateway touchpoint: [ui/src/i18n/locales/en.ts](ui/src/i18n/locales/en.ts), [ui/src/i18n/locales/zh-CN.ts](ui/src/i18n/locales/zh-CN.ts), [src/gateway/server-methods/chat.ts](src/gateway/server-methods/chat.ts)
## Execution Plan
1. **Sync safely before merge**
- Fetch latest remote refs and verify with `origin/main` (not just local `main`).
- Keep existing untracked workspace content untouched (notably `openclaw/`) and avoid broad staging commands.
1. **Merge `origin/main` into branch**
- Run a regular merge into `ui/dashboard-v2` (no rebase), stop on conflicts, and resolve in a fixed order (below).
- Commit exactly one merge commit after all conflicts are resolved and verified.
1. **Resolve conflicts by risk tier**
- **Tier 1 (logic + high churn):** cron/controller/render/state files.
- Keep API/contract changes from `main` where behavior diverged.
- Reapply dashboard-v2 UX improvements (new layout/component structure) on top of `main` semantics.
- **Tier 2 (styles):** base/components/config/chat layout styles.
- Prefer `main` design tokens/variables and re-layer dashboard-v2 visuals to reduce regressions.
- **Tier 3 (i18n + gateway edge):** locale files and gateway chat method.
- Preserve any new keys/contract updates from `main`; then reintroduce dashboard-v2 strings/behavior.
- **Tier 4 (tests):** update tests only after source is stable.
- Reconcile deleted/replaced tests and align with final merged behavior.
1. **Validate and stabilize**
- Run targeted UI tests first (cron, config, navigation, chat), then full checks:
- `pnpm test` (or targeted vitest subsets first)
- `pnpm check`
- `pnpm build`
- Fix only merge-induced regressions; avoid opportunistic refactors during conflict resolution.
1. **Prepare one reviewable PR with logical sections**
- Keep a single PR (your preference), but structure the PR description and commit narrative into clear sections:
- **Dashboard shell + navigation/state**
- **Chat UX features** (slash commands, pinned/deleted/input history/speech)
- **Overview panels and login gate**
- **Cron UX + controller updates**
- **Visual/theme refresh (CSS/icons/assets)**
- **Tests and expectation updates**
- This gives reviewer-friendly scope boundaries now and preserves a future path to split follow-up PRs if requested.
## Future Split Opportunities (document-only for now)
If this still feels too large after merge, the cleanest extraction candidates are:
- **PR A: Visual-only refresh**: [ui/src/styles](ui/src/styles), [ui/index.html](ui/index.html), [ui/public](ui/public)
- **PR B: Chat UX capabilities**: [ui/src/ui/chat](ui/src/ui/chat), [ui/src/ui/views/chat.ts](ui/src/ui/views/chat.ts), [ui/src/ui/app-chat.ts](ui/src/ui/app-chat.ts)
- **PR C: Overview/dashboard composition**: [ui/src/ui/views/overview.ts](ui/src/ui/views/overview.ts) plus new overview partials and [ui/src/ui/components/dashboard-header.ts](ui/src/ui/components/dashboard-header.ts)
- **PR D: Cron surface + state handling**: [ui/src/ui/views/cron.ts](ui/src/ui/views/cron.ts), [ui/src/ui/controllers/cron.ts](ui/src/ui/controllers/cron.ts)
```mermaid
flowchart TD
syncMain[Sync origin_main refs] --> mergeStep[Merge origin_main into ui_dashboard_v2]
mergeStep --> resolveTier1[Resolve Tier1 logic conflicts]
resolveTier1 --> resolveTier2[Resolve Tier2 style conflicts]
resolveTier2 --> resolveTier3[Resolve Tier3 i18n_gateway conflicts]
resolveTier3 --> resolveTier4[Resolve Tier4 test conflicts]
resolveTier4 --> verifyAll[Run tests check build]
verifyAll --> prCompose[Compose single PR with logical sections]
```

1
openclaw Submodule

@ -0,0 +1 @@
Subproject commit 3ec10870c1ff47eecd510cbf61b5c8c37d623c0f

View File

@ -314,6 +314,60 @@ function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; chan
return { block: changed ? entry : block, changed };
}
/**
* Validate that a value is a finite number, returning undefined otherwise.
*/
function toFiniteNumber(x: unknown): number | undefined {
return typeof x === "number" && Number.isFinite(x) ? x : undefined;
}
/**
* Sanitize usage metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from malformed transcript JSON.
*/
function sanitizeUsage(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const u = raw as Record<string, unknown>;
const out: Record<string, number> = {};
// Whitelist known usage fields and validate they're finite numbers
const knownFields = [
"input",
"output",
"totalTokens",
"inputTokens",
"outputTokens",
"cacheRead",
"cacheWrite",
"cache_read_input_tokens",
"cache_creation_input_tokens",
];
for (const k of knownFields) {
const n = toFiniteNumber(u[k]);
if (n !== undefined) {
out[k] = n;
}
}
return Object.keys(out).length > 0 ? out : undefined;
}
/**
* Sanitize cost metadata to ensure only finite numeric fields are included.
* Prevents UI crashes from calling .toFixed() on non-numbers.
*/
function sanitizeCost(raw: unknown): { total?: number } | undefined {
if (!raw || typeof raw !== "object") {
return undefined;
}
const c = raw as Record<string, unknown>;
const total = toFiniteNumber(c.total);
return total !== undefined ? { total } : undefined;
}
function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } {
if (!message || typeof message !== "object") {
return { message, changed: false };
@ -325,13 +379,38 @@ 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;
// Keep usage/cost so the chat UI can render per-message token and cost badges.
// Only retain usage/cost on assistant messages and validate numeric fields to prevent UI crashes.
if (entry.role !== "assistant") {
if ("usage" in entry) {
delete entry.usage;
changed = true;
}
if ("cost" in entry) {
delete entry.cost;
changed = true;
}
} else {
// Validate and sanitize usage/cost for assistant messages
if ("usage" in entry) {
const sanitized = sanitizeUsage(entry.usage);
if (sanitized) {
entry.usage = sanitized;
} else {
delete entry.usage;
}
changed = true;
}
if ("cost" in entry) {
const sanitized = sanitizeCost(entry.cost);
if (sanitized) {
entry.cost = sanitized;
} else {
delete entry.cost;
}
changed = true;
}
}
if (typeof entry.content === "string") {

View File

@ -273,6 +273,37 @@ describe("gateway server chat", () => {
});
});
test("chat.history preserves usage and cost metadata for assistant messages", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);
const sessionDir = await createSessionDir();
await writeMainSessionStore();
await writeMainSessionTranscript(sessionDir, [
JSON.stringify({
message: {
role: "assistant",
timestamp: Date.now(),
content: [{ type: "text", text: "hello" }],
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
details: { debug: true },
},
}),
]);
const messages = await fetchHistoryMessages(ws);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: "assistant",
usage: { input: 12, output: 5, totalTokens: 17 },
cost: { total: 0.0123 },
});
expect(messages[0]).not.toHaveProperty("details");
});
});
test("chat.history strips inline directives from displayed message text", async () => {
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
await connectOk(ws);

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,6 +26,8 @@ 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;
@ -170,7 +176,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 +185,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 +221,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

@ -14,7 +14,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";
@ -26,6 +26,7 @@ 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 {
@ -39,7 +40,7 @@ import type { UiSettings } from "./storage.ts";
import type {
AgentsListResult,
PresenceEntry,
HealthSnapshot,
HealthSummary,
StatusSummary,
UpdateAvailable,
} from "./types.ts";
@ -81,10 +82,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;
@ -221,7 +222,7 @@ 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]);
@ -321,12 +322,13 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
}
}
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;
}
@ -406,7 +408,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as
| {
presence?: PresenceEntry[];
health?: HealthSnapshot;
health?: HealthSummary;
sessionDefaults?: SessionDefaultsSnapshot;
updateAvailable?: UpdateAvailable;
}
@ -416,6 +418,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,9 +1,29 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
},
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { language: "en-US" },
});
return {};
});
import {
isCronSessionKey,
parseSessionKey,
resolveSessionOptionGroups,
resolveSessionDisplayName,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
import type { SessionsListResult } from "./types.ts";
type SessionRow = SessionsListResult["sessions"][number];
@ -12,6 +32,14 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
return { kind: "direct", updatedAt: 0, ...overrides };
}
function testState(overrides: Partial<AppViewState> = {}): AppViewState {
return {
agentsList: null,
sessionsHideCron: true,
...overrides,
} as AppViewState;
}
/* ================================================================
* parseSessionKey low-level key type / fallback mapping
* ================================================================ */
@ -284,3 +312,40 @@ describe("isCronSessionKey", () => {
expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false);
});
});
describe("resolveSessionOptionGroups", () => {
const sessions: SessionsListResult = {
sessions: [
row({ key: "agent:main:main" }),
row({ key: "agent:main:cron:daily" }),
row({ key: "agent:main:discord:direct:user-1" }),
],
};
it("filters cron sessions from options when the hide toggle is enabled", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:main",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:discord:direct:user-1",
]);
});
it("retains the active cron session even when cron sessions are hidden", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:cron:daily",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:cron:daily",
"agent:main:discord:direct:user-1",
]);
});
});

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,75 @@ 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[];
};
export 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 hideCron = state.sessionsHideCron ?? true;
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) {
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
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 +520,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, loadCronRuns, 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,15 @@ 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";
import type { AgentsListResult, AttentionItem } from "./types.ts";
import { resetChatViewState } from "./views/chat.ts";
type SettingsHost = {
settings: UiSettings;
password?: string;
theme: ThemeMode;
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
applySessionKey: string;
sessionKey: string;
@ -56,9 +55,8 @@ 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;
systemThemeCleanup?: (() => void) | null;
pendingGatewayToken?: string | null;
};
@ -69,9 +67,10 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
};
host.settings = normalized;
saveSettings(normalized);
if (next.theme !== host.theme) {
if (next.theme !== host.theme || next.themeMode !== host.themeMode) {
host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme));
host.themeMode = next.themeMode;
applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode));
}
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@ -166,18 +165,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) {
@ -201,7 +218,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) {
@ -235,7 +251,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);
}
@ -262,8 +285,19 @@ 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) {
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@ -273,44 +307,35 @@ 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) {
// Clean up existing listener if mode is not "system"
if (host.themeMode !== "system") {
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
return;
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") {
// Skip if listener already attached for this host
if (host.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);
host.systemThemeCleanup = () => mql.removeEventListener("change", onChange);
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@ -354,9 +379,16 @@ function applyTabSelection(
next: Tab,
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
) {
const prev = host.tab;
if (host.tab !== next) {
host.tab = next;
}
// Cleanup chat module state when navigating away from chat
if (prev === "chat" && next !== "chat") {
resetChatViewState();
}
if (next === "chat") {
host.chatHasAutoScrolled = false;
}
@ -419,13 +451,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) {
@ -437,18 +599,12 @@ export async function loadChannelsTab(host: SettingsHost) {
}
export async function loadCron(host: SettingsHost) {
const cronHost = host as unknown as OpenClawApp;
const app = host as unknown as OpenClawApp;
const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null;
await Promise.all([
loadChannels(host as unknown as OpenClawApp, false),
loadCronStatus(cronHost),
loadCronJobs(cronHost),
loadCronModelSuggestions(cronHost),
loadChannels(app, false),
loadCronStatus(app),
loadCronJobs(app),
loadCronRuns(app, activeCronJobId),
]);
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,8 +29,8 @@ import type {
SessionUsageTimeSeries,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
ToolsCatalogResult,
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
@ -37,12 +39,16 @@ import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
settings: UiSettings;
password: string;
loginShowGatewayToken: boolean;
loginShowGatewayPassword: boolean;
tab: Tab;
onboarding: boolean;
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: "light" | "dark";
theme: ThemeName;
themeMode: ThemeMode;
themeResolved: ResolvedTheme;
themeOrder: ThemeName[];
hello: GatewayHelloOk | null;
lastError: string | null;
lastErrorCode: string | null;
@ -110,6 +116,26 @@ export type AppViewState = {
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
communicationsFormMode: "form" | "raw";
communicationsSearchQuery: string;
communicationsActiveSection: string | null;
communicationsActiveSubsection: string | null;
appearanceFormMode: "form" | "raw";
appearanceSearchQuery: string;
appearanceActiveSection: string | null;
appearanceActiveSubsection: string | null;
automationFormMode: "form" | "raw";
automationSearchQuery: string;
automationActiveSection: string | null;
automationActiveSubsection: string | null;
infrastructureFormMode: "form" | "raw";
infrastructureSearchQuery: string;
infrastructureActiveSection: string | null;
infrastructureActiveSubsection: string | null;
aiAgentsFormMode: "form" | "raw";
aiAgentsSearchQuery: string;
aiAgentsActiveSection: string | null;
aiAgentsActiveSubsection: string | null;
channelsLoading: boolean;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsError: string | null;
@ -155,6 +181,12 @@ export type AppViewState = {
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsHideCron: boolean;
sessionsSearchQuery: string;
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
sessionsSortDir: "asc" | "desc";
sessionsPage: number;
sessionsPageSize: number;
sessionsActionsOpenKey: string | null;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;
@ -233,10 +265,13 @@ export type AppViewState = {
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHealth: HealthSummary | null;
debugModels: ModelCatalogEntry[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
@ -256,11 +291,21 @@ export type AppViewState = {
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
attentionItems: AttentionItem[];
paletteOpen: boolean;
paletteQuery: string;
paletteActiveIndex: number;
streamMode: boolean;
overviewShowGatewayToken: boolean;
overviewShowGatewayPassword: boolean;
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
applySettings: (next: UiSettings) => void;
loadOverview: () => Promise<void>;
loadAssistantIdentity: () => Promise<void>;

View File

@ -42,6 +42,7 @@ import {
loadOverview as loadOverviewInternal,
setTab as setTabInternal,
setTheme as setThemeInternal,
setThemeMode as setThemeModeInternal,
onPopState as onPopStateInternal,
} from "./app-settings.ts";
import {
@ -52,8 +53,8 @@ import {
} from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { exportChatMarkdown } from "./chat/export.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 +62,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.ts";
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@ -71,19 +72,19 @@ import type {
CronJob,
CronRunLogEntry,
CronStatus,
HealthSnapshot,
HealthSummary,
LogEntry,
LogLevel,
ModelCatalogEntry,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
SkillStatusReport,
ToolsCatalogResult,
StatusSummary,
NostrProfile,
ToolsCatalogResult,
} 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 {
@ -120,11 +121,15 @@ 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;
@ -155,6 +160,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;
@ -201,6 +209,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;
@ -250,6 +278,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;
@ -324,7 +358,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;
@ -344,6 +378,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;
@ -352,10 +405,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 = "{}";
@ -394,9 +451,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;
@ -404,6 +469,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]);
}
@ -412,6 +491,7 @@ export class OpenClawApp extends LitElement {
}
disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler);
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
@ -471,8 +551,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() {

1
ui/src/ui/chat-export.ts Normal file
View File

@ -0,0 +1 @@
export { exportChatMarkdown } from "./chat/export.ts";

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]));
}
}

24
ui/src/ui/chat/export.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* Export chat history as markdown file.
*/
export 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 link = document.createElement("a");
link.href = url;
link.download = `chat-${assistantName}-${Date.now()}.md`;
link.click();
URL.revokeObjectURL(url);
}

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);
@ -122,20 +129,35 @@ export function renderMessageGroup(
? (userLabel ?? "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(
@ -150,24 +172,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"
@ -185,7 +423,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>`;
@ -222,6 +474,79 @@ 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>
`;
}
/**
* Max characters for auto-detecting and pretty-printing JSON.
* Prevents DoS from large JSON payloads in assistant/tool messages.
*/
const MAX_JSON_AUTOPARSE_CHARS = 20_000;
/**
* Detect whether a trimmed string is a JSON object or array.
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
* Size-capped to prevent render-loop DoS from large JSON messages.
*/
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
const t = text.trim();
// Enforce size cap to prevent UI freeze from multi-MB JSON payloads
if (t.length > MAX_JSON_AUTOPARSE_CHARS) {
return null;
}
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 },
@ -229,6 +554,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" ||
@ -249,40 +575,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,83 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string): GatewaySessionRow {
return {
key,
kind: "direct",
updatedAt: null,
};
}
describe("executeSlashCommand /kill", () => {
it("aborts every sub-agent session for /kill all", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one"),
row("agent:main:subagent:parent:subagent:child"),
row("agent:other:main"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
it("aborts matching sub-agent sessions for /kill <agentId>", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one"),
row("agent:main:subagent:two"),
row("agent:other:subagent:three"),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"main",
);
expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
});

View File

@ -0,0 +1,370 @@
/**
* Client-side execution engine for slash commands.
* Calls gateway RPC methods and returns formatted results.
*/
import { isSubagentSessionKey, parseAgentSessionKey } from "../../../../src/routing/session-key.js";
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, sessionKey, 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,
sessionKey: string,
args: string,
): Promise<SlashCommandResult> {
const target = args.trim();
if (!target) {
return { content: "Usage: `/kill <id|all>`" };
}
try {
const sessions = await client.request<SessionsListResult>("sessions.list", {});
const matched = resolveKillTargets(sessions?.sessions ?? [], sessionKey, target);
if (matched.length === 0) {
return {
content:
target.toLowerCase() === "all"
? "No active sub-agent sessions found."
: `No matching sub-agent sessions found for \`${target}\`.`,
};
}
const results = await Promise.allSettled(
matched.map((key) => client.request("chat.abort", { sessionKey: key })),
);
const successCount = results.filter((entry) => entry.status === "fulfilled").length;
if (successCount === 0) {
const firstFailure = results.find((entry) => entry.status === "rejected");
throw firstFailure?.reason ?? new Error("abort failed");
}
if (target.toLowerCase() === "all") {
return {
content:
successCount === matched.length
? `Aborted ${successCount} sub-agent session${successCount === 1 ? "" : "s"}.`
: `Aborted ${successCount} of ${matched.length} sub-agent sessions.`,
};
}
return {
content:
successCount === matched.length
? `Aborted ${successCount} matching sub-agent session${successCount === 1 ? "" : "s"} for \`${target}\`.`
: `Aborted ${successCount} of ${matched.length} matching sub-agent sessions for \`${target}\`.`,
};
} catch (err) {
return { content: `Failed to abort: ${String(err)}` };
}
}
function resolveKillTargets(
sessions: GatewaySessionRow[],
currentSessionKey: string,
target: string,
): string[] {
const normalizedTarget = target.trim().toLowerCase();
if (!normalizedTarget) {
return [];
}
const keys = new Set<string>();
const currentParsed = parseAgentSessionKey(currentSessionKey);
for (const session of sessions) {
const key = session?.key?.trim();
if (!key || !isSubagentSessionKey(key)) {
continue;
}
const normalizedKey = key.toLowerCase();
const parsed = parseAgentSessionKey(normalizedKey);
const isMatch =
normalizedTarget === "all" ||
normalizedKey === normalizedTarget ||
(parsed?.agentId ?? "") === normalizedTarget ||
normalizedKey.endsWith(`:subagent:${normalizedTarget}`) ||
normalizedKey === `subagent:${normalizedTarget}` ||
(currentParsed?.agentId != null &&
parsed?.agentId === currentParsed.agentId &&
normalizedKey.endsWith(`:subagent:${normalizedTarget}`));
if (isMatch) {
keys.add(key);
}
}
return [...keys];
}
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

@ -51,7 +51,7 @@ describe("config form renderer", () => {
container,
);
const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']");
const tokenInput: HTMLInputElement | null = container.querySelector(".cfg-input");
expect(tokenInput).not.toBeNull();
if (!tokenInput) {
return;
@ -77,6 +77,81 @@ describe("config form renderer", () => {
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
});
it("keeps sensitive values out of hidden form inputs until revealed", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
const revealed = new Set<string>();
const props = {
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: false,
isSensitivePathRevealed: (path: Array<string | number>) =>
revealed.has(
path.filter((segment): segment is string => typeof segment === "string").join("."),
),
onToggleSensitivePath: (path: Array<string | number>) => {
const key = path
.filter((segment): segment is string => typeof segment === "string")
.join(".");
if (revealed.has(key)) {
revealed.delete(key);
} else {
revealed.add(key);
}
},
onPatch,
};
render(renderConfigForm(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(hiddenInput).not.toBeNull();
expect(hiddenInput?.value).toBe("");
expect(hiddenInput?.placeholder).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Reveal value"]');
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfigForm(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(revealedInput?.value).toBe("secret-123");
expect(revealedInput?.type).toBe("text");
});
it("blocks sensitive field reveal while stream mode is enabled", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: true,
isSensitivePathRevealed: () => false,
onToggleSensitivePath: vi.fn(),
onPatch,
}),
container,
);
const input = container.querySelector<HTMLInputElement>(".cfg-input");
expect(input?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Disable stream mode to reveal value"]',
);
expect(toggle?.disabled).toBe(true);
});
it("adds and removes array entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
@ -301,7 +376,7 @@ describe("config form renderer", () => {
}),
noMatchContainer,
);
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
expect(noMatchContainer.textContent).not.toContain("Token");
});
it("supports SecretInput unions in additionalProperties maps", () => {
@ -371,7 +446,9 @@ describe("config form renderer", () => {
container,
);
const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']");
const apiKeyInput: HTMLInputElement | null = container.querySelector(
".cfg-input:not(.cfg-input--sm)",
);
expect(apiKeyInput).not.toBeNull();
if (!apiKeyInput) {
return;

View File

@ -14,6 +14,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
agentsList: null,
agentsSelectedId: "main",
toolsCatalogLoading: false,
toolsCatalogLoadingAgentId: null,
toolsCatalogError: null,
toolsCatalogResult: null,
};
@ -149,6 +150,70 @@ describe("loadToolsCatalog", () => {
expect(state.toolsCatalogError).toContain("gateway unavailable");
expect(state.toolsCatalogLoading).toBe(false);
});
it("allows a new agent request to replace a stale in-flight load", async () => {
const { state, request } = createState();
let resolveMain:
| ((value: {
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}) => void)
| null = null;
const mainRequest = new Promise<{
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}>((resolve) => {
resolveMain = resolve;
});
const replacementPayload = {
agentId: "other",
profiles: [{ id: "full", label: "Full" }],
groups: [],
};
request.mockImplementationOnce(() => mainRequest).mockResolvedValueOnce(replacementPayload);
const initialLoad = loadToolsCatalog(state, "main");
await Promise.resolve();
state.agentsSelectedId = "other";
await loadToolsCatalog(state, "other");
expect(request).toHaveBeenNthCalledWith(1, "tools.catalog", {
agentId: "main",
includePlugins: true,
});
expect(request).toHaveBeenNthCalledWith(2, "tools.catalog", {
agentId: "other",
includePlugins: true,
});
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
resolveMain?.({
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [],
});
await initialLoad;
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
});
});
describe("saveAgentsConfig", () => {

View File

@ -11,6 +11,7 @@ export type AgentsState = {
agentsList: AgentsListResult | null;
agentsSelectedId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogLoadingAgentId?: string | null;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
};
@ -43,27 +44,44 @@ export async function loadAgents(state: AgentsState) {
}
}
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) {
if (!state.client || !state.connected) {
export async function loadToolsCatalog(state: AgentsState, agentId: string) {
const resolvedAgentId = agentId.trim();
if (!state.client || !state.connected || !resolvedAgentId) {
return;
}
if (state.toolsCatalogLoading) {
if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
return;
}
state.toolsCatalogLoading = true;
state.toolsCatalogLoadingAgentId = resolvedAgentId;
state.toolsCatalogError = null;
state.toolsCatalogResult = null;
try {
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
agentId: agentId ?? state.agentsSelectedId ?? undefined,
agentId: resolvedAgentId,
includePlugins: true,
});
if (res) {
state.toolsCatalogResult = res;
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
return;
}
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = res;
} catch (err) {
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
return;
}
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = null;
state.toolsCatalogError = String(err);
} finally {
state.toolsCatalogLoading = false;
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
state.toolsCatalogLoadingAgentId = null;
state.toolsCatalogLoading = false;
}
}
}

View File

@ -294,6 +294,50 @@ describe("applyConfig", () => {
expect(params.baseHash).toBe("hash-apply-1");
expect(params.sessionKey).toBe("agent:main:web:dm:test");
});
it("preserves sensitive form values in serialized raw state for apply", async () => {
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:web:dm:secret";
state.configFormMode = "form";
state.configForm = {
gateway: {
auth: {
token: "secret-123",
},
},
};
state.configSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
},
};
state.configSnapshot = { hash: "hash-apply-secret" };
await applyConfig(state);
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
const params = request.mock.calls[0]?.[1] as {
raw: string;
baseHash: string;
sessionKey: string;
};
expect(params.raw).toContain("secret-123");
expect(params.baseHash).toBe("hash-apply-secret");
});
});
describe("saveConfig", () => {

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,
@ -45,6 +62,7 @@ const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
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);
@ -83,6 +101,9 @@ function installHooks() {
}
node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank");
if (href.toLowerCase().includes("tail")) {
node.classList.add(TAIL_LINK_BLUR_CLASS);
}
});
}
@ -152,6 +173,43 @@ function normalizeMarkdownImageLabel(text?: string | null): string {
return trimmed ? trimmed : "image";
}
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

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

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

@ -6,7 +6,7 @@ import type {
SessionsListResultBase,
SessionsPatchResultBase,
} from "../../../src/shared/session-types.js";
export type { ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
export type ChannelsStatusSnapshot = {
ts: number;
@ -329,35 +329,6 @@ export type AgentsListResult = {
agents: GatewayAgentRow[];
};
export type ToolCatalogProfile = {
id: "minimal" | "coding" | "messaging" | "full";
label: string;
};
export type ToolCatalogEntry = {
id: string;
label: string;
description: string;
source: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles: Array<"minimal" | "coding" | "messaging" | "full">;
};
export type ToolCatalogGroup = {
id: string;
label: string;
source: "core" | "plugin";
pluginId?: string;
tools: ToolCatalogEntry[];
};
export type ToolsCatalogResult = {
agentId: string;
profiles: ToolCatalogProfile[];
groups: ToolCatalogGroup[];
};
export type AgentIdentityResult = {
agentId: string;
name: string;
@ -510,58 +481,17 @@ export type CronStatus = {
nextWakeAtMs?: number | null;
};
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
export type CronSortDir = "asc" | "desc";
export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped";
export type CronRunsStatusValue = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
export type CronRunScope = "job" | "all";
export type CronRunLogEntry = {
ts: number;
jobId: string;
jobName?: string;
status?: CronRunsStatusValue;
status: "ok" | "error" | "skipped";
durationMs?: number;
error?: string;
summary?: string;
deliveryStatus?: CronDeliveryStatus;
deliveryError?: string;
delivered?: boolean;
runAtMs?: number;
nextRunAtMs?: number;
model?: string;
provider?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
cache_read_tokens?: number;
cache_write_tokens?: number;
};
sessionId?: string;
sessionKey?: string;
};
export type CronJobsListResult = {
jobs?: CronJob[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type CronRunsResult = {
entries?: CronRunLogEntry[];
total?: number;
offset?: number;
limit?: number;
hasMore?: boolean;
nextOffset?: number | null;
};
export type SkillsStatusConfigCheck = {
path: string;
satisfied: boolean;
@ -615,6 +545,44 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */
export type HealthSummary = {
ok: boolean;
ts: number;
durationMs: number;
heartbeatSeconds: number;
defaultAgentId: string;
agents: Array<{ id: string; name?: string }>;
sessions: {
path: string;
count: number;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
};
/** A model entry returned by the gateway model-catalog endpoint. */
export type ModelCatalogEntry = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
export type ToolCatalogProfile =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogProfile;
export type ToolCatalogEntry =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogEntry;
export type ToolCatalogGroup =
import("../../../src/gateway/protocol/schema/types.js").ToolCatalogGroup;
export type ToolsCatalogResult =
import("../../../src/gateway/protocol/schema/types.js").ToolsCatalogResult;
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
@ -625,3 +593,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

@ -2,12 +2,14 @@ import { html, nothing } from "lit";
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
import {
type AgentToolEntry,
type AgentToolSection,
isAllowedByPolicy,
matchesList,
PROFILE_OPTIONS,
resolveAgentConfig,
resolveToolProfileOptions,
resolveToolProfile,
TOOL_SECTIONS,
resolveToolSections,
} from "./agents-utils.ts";
import type { SkillGroup } from "./skills-grouping.ts";
import { groupSkills } from "./skills-grouping.ts";
@ -17,6 +19,28 @@ import {
renderSkillStatusChips,
} from "./skills-shared.ts";
function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) {
const source = tool.source ?? section.source;
const pluginId = tool.pluginId ?? section.pluginId;
const badges: string[] = [];
if (source === "plugin" && pluginId) {
badges.push(`plugin:${pluginId}`);
} else if (source === "core") {
badges.push("core");
}
if (tool.optional) {
badges.push("optional");
}
if (badges.length === 0) {
return nothing;
}
return html`
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
${badges.map((badge) => html`<span class="agent-pill">${badge}</span>`)}
</div>
`;
}
export function renderAgentTools(params: {
agentId: string;
configForm: Record<string, unknown> | null;
@ -35,6 +59,8 @@ export function renderAgentTools(params: {
const agentTools = config.entry?.tools ?? {};
const globalTools = config.globalTools ?? {};
const profile = agentTools.profile ?? globalTools.profile ?? "full";
const profileOptions = resolveToolProfileOptions(params.toolsCatalogResult);
const toolSections = resolveToolSections(params.toolsCatalogResult);
const profileSource = agentTools.profile
? "agent override"
: globalTools.profile
@ -43,7 +69,11 @@ export function renderAgentTools(params: {
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable =
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow;
Boolean(params.configForm) &&
!params.configLoading &&
!params.configSaving &&
!hasAgentAllow &&
!(params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError);
const alsoAllow = hasAgentAllow
? []
: Array.isArray(agentTools.alsoAllow)
@ -53,17 +83,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 = toolSections.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
@ -152,15 +172,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`
@ -188,6 +199,22 @@ export function renderAgentTools(params: {
`
: nothing
}
${
params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">Loading runtime tool catalog</div>
`
: nothing
}
${
params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load runtime tool catalog. Showing built-in fallback list instead.
</div>
`
: nothing
}
<div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv">
@ -235,50 +262,27 @@ export function renderAgentTools(params: {
</div>
<div class="agent-tools-grid" style="margin-top: 20px;">
${sections.map(
${toolSections.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>
`
section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
: nothing
}
</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>
${renderToolBadges(section, tool)}
</div>
<label class="cfg-toggle">
<input
@ -298,13 +302,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 +358,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,18 +1,157 @@
import { html } from "lit";
import {
listCoreToolSections,
PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS,
} from "../../../../src/agents/tool-catalog.js";
import {
expandToolGroups,
normalizeToolName,
resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy-shared.js";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import type {
AgentIdentityResult,
AgentsFilesListResult,
AgentsListResult,
ToolCatalogProfile,
ToolsCatalogResult,
} from "../types.ts";
export const TOOL_SECTIONS = listCoreToolSections();
export type AgentToolEntry = {
id: string;
label: string;
description: string;
source?: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles?: string[];
};
export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS;
export type AgentToolSection = {
id: string;
label: string;
source?: "core" | "plugin";
pluginId?: string;
tools: AgentToolEntry[];
};
export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [
{
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 = [
{ id: "minimal", label: "Minimal" },
{ id: "coding", label: "Coding" },
{ id: "messaging", label: "Messaging" },
{ id: "full", label: "Full" },
] as const;
export function resolveToolSections(
toolsCatalogResult: ToolsCatalogResult | null,
): AgentToolSection[] {
if (toolsCatalogResult?.groups?.length) {
return toolsCatalogResult.groups.map((group) => ({
id: group.id,
label: group.label,
source: group.source,
pluginId: group.pluginId,
tools: group.tools.map((tool) => ({
id: tool.id,
label: tool.label,
description: tool.description,
source: tool.source,
pluginId: tool.pluginId,
optional: tool.optional,
defaultProfiles: [...tool.defaultProfiles],
})),
}));
}
return FALLBACK_TOOL_SECTIONS;
}
export function resolveToolProfileOptions(
toolsCatalogResult: ToolsCatalogResult | null,
): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS {
if (toolsCatalogResult?.profiles?.length) {
return toolsCatalogResult.profiles;
}
return PROFILE_OPTIONS;
}
type ToolPolicy = {
allow?: string[];
@ -55,6 +194,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 +269,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 +309,7 @@ export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
identityAvatar: string;
skillsLabel: string;
isDefault: boolean;
};
@ -164,14 +335,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

@ -9,64 +9,78 @@ import type {
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 ToolsCatalogState = {
loading: boolean;
error: string | null;
result: ToolsCatalogResult | null;
};
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;
toolsCatalog: ToolsCatalogState;
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void;
@ -83,20 +97,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 +114,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 +215,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 +242,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 +261,13 @@ 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,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
@ -228,15 +279,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 +303,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 +322,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 +344,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 +368,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

@ -1,10 +1,13 @@
import { html, nothing, type TemplateResult } from "lit";
import { icons as sharedIcons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts";
import {
defaultValue,
hasSensitiveConfigData,
hintForPath,
humanize,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
@ -100,11 +103,79 @@ type FieldMeta = {
tags: string[];
};
type SensitiveRenderParams = {
path: Array<string | number>;
value: unknown;
hints: ConfigUiHints;
streamMode: boolean;
revealSensitive: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
};
type SensitiveRenderState = {
isSensitive: boolean;
isRedacted: boolean;
isRevealed: boolean;
canReveal: boolean;
};
export type ConfigSearchCriteria = {
text: string;
tags: string[];
};
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
const isRevealed =
isSensitive &&
!params.streamMode &&
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
return {
isSensitive,
isRedacted: isSensitive && !isRevealed,
isRevealed,
canReveal: isSensitive && !params.streamMode,
};
}
function renderSensitiveToggleButton(params: {
path: Array<string | number>;
state: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
}): TemplateResult | typeof nothing {
const { state } = params;
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
return html`
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
title=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-label=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
`;
}
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
}
@ -331,6 +402,10 @@ export function renderNode(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
@ -440,6 +515,21 @@ export function renderNode(params: {
});
}
}
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea({
schema,
value,
path,
hints,
disabled,
showLabel,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
});
}
// Enum - use segmented for small, dropdown for large
@ -537,6 +627,10 @@ function renderTextInput(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
@ -544,17 +638,23 @@ function renderTextInput(params: {
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const isSensitive =
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
const placeholder =
hint?.placeholder ??
// oxlint-disable typescript/no-base-to-string
(isSensitive
? "••••"
: schema.default !== undefined
? `Default: ${String(schema.default)}`
: "");
const displayValue = value ?? "";
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const placeholder = sensitiveState.isRedacted
? REDACTED_PLACEHOLDER
: (hint?.placeholder ??
// oxlint-disable typescript/no-base-to-string
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
const displayValue = sensitiveState.isRedacted ? "" : (value ?? "");
const effectiveDisabled = disabled || sensitiveState.isRedacted;
const effectiveInputType =
sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType;
return html`
<div class="cfg-field">
@ -563,12 +663,16 @@ function renderTextInput(params: {
${renderTags(tags)}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
type=${effectiveInputType}
class="cfg-input"
placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@input=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") {
if (raw.trim() === "") {
@ -582,13 +686,19 @@ function renderTextInput(params: {
onPatch(path, raw);
}}
@change=${(e: Event) => {
if (inputType === "number") {
if (inputType === "number" || sensitiveState.isRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim());
}}
/>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${
schema.default !== undefined
? html`
@ -596,7 +706,7 @@ function renderTextInput(params: {
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled}
?disabled=${effectiveDisabled}
@click=${() => onPatch(path, schema.default)}
></button>
`
@ -702,6 +812,75 @@ function renderSelect(params: {
`;
}
function renderJsonTextarea(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
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);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
streamMode: params.streamMode ?? false,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const displayValue = sensitiveState.isRedacted ? "" : fallback;
const effectiveDisabled = disabled || sensitiveState.isRedacted;
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)}
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea"
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
rows="3"
.value=${displayValue}
?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
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>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
</div>
</div>
`;
}
function renderObject(params: {
schema: JsonSchema;
value: unknown;
@ -711,9 +890,26 @@ function renderObject(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
@ -754,6 +950,10 @@ function renderObject(params: {
unsupported,
disabled,
searchCriteria: childSearchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
}),
)}
@ -768,6 +968,10 @@ function renderObject(params: {
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing
@ -818,9 +1022,26 @@ function renderArray(params: {
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
@ -900,6 +1121,10 @@ function renderArray(params: {
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
@ -922,6 +1147,10 @@ function renderMapField(params: {
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
@ -934,6 +1163,10 @@ function renderMapField(params: {
reservedKeys,
onPatch,
searchCriteria,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
@ -985,6 +1218,14 @@ function renderMapField(params: {
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
streamMode: streamMode ?? false,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-header">
@ -1028,26 +1269,40 @@ function renderMapField(params: {
${
anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
rows="2"
.value=${fallback}
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder=${
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
rows="2"
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled || sensitiveState.isRedacted}
?readonly=${sensitiveState.isRedacted}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path: valuePath,
state: sensitiveState,
disabled,
onToggleSensitivePath,
})}
</div>
`
: renderNode({
schema,
@ -1058,6 +1313,10 @@ function renderMapField(params: {
disabled,
searchCriteria,
showLabel: false,
streamMode,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
}

View File

@ -13,6 +13,10 @@ export type ConfigFormProps = {
searchQuery?: string;
activeSection?: string | null;
activeSubsection?: string | null;
streamMode?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
@ -431,6 +435,10 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
streamMode: props.streamMode ?? false,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch,
})}
</div>
@ -466,6 +474,10 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
streamMode: props.streamMode ?? false,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch,
})}
</div>

View File

@ -1,4 +1,4 @@
import type { ConfigUiHints } from "../types.ts";
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
export type JsonSchema = {
type?: string | string[];
@ -94,3 +94,110 @@ export function humanize(raw: string) {
.replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase());
}
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"tokenlimit",
"tokenbudget",
"passwordfile",
] as const;
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/serviceaccount(?:ref)?$/i,
];
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
export function isSensitiveConfigPath(path: string): boolean {
const lowerPath = path.toLowerCase();
const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
function isSensitiveLeafValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0 && !isEnvVarPlaceholder(value);
}
return value !== undefined && value !== null;
}
function isHintSensitive(hint: ConfigUiHint | undefined): boolean {
return hint?.sensitive ?? false;
}
export function hasSensitiveConfigData(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): boolean {
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return true;
}
if (Array.isArray(value)) {
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
hasSensitiveConfigData(childValue, [...path, childKey], hints),
);
}
return false;
}
export function countSensitiveConfigValues(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): number {
if (value == null) {
return 0;
}
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return 1;
}
if (Array.isArray(value)) {
return value.reduce(
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
0,
);
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).reduce(
(count, [childKey, childValue]) =>
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
0,
);
}
return 0;
}

View File

@ -1,8 +1,12 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderConfig } from "./config.ts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderConfig, resetConfigViewStateForTests } from "./config.ts";
describe("config view", () => {
beforeEach(() => {
resetConfigViewStateForTests();
});
const baseProps = () => ({
raw: "{\n}\n",
originalRaw: "{\n}\n",
@ -20,11 +24,13 @@ describe("config view", () => {
schemaLoading: false,
uiHints: {},
formMode: "form" as const,
showModeToggle: true,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
@ -35,6 +41,13 @@ describe("config view", () => {
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
version: "",
theme: "claw" as const,
themeMode: "system" as const,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
gatewayUrl: "",
assistantName: "",
});
function findActionButtons(container: HTMLElement): {
@ -134,6 +147,102 @@ describe("config view", () => {
expect(applyButton?.disabled).toBe(false);
});
it("keeps raw secrets out of the DOM while stream mode is enabled", () => {
const container = document.createElement("div");
render(
renderConfig({
...baseProps(),
formMode: "raw",
streamMode: true,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
}),
container,
);
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("");
expect(textarea?.getAttribute("placeholder")).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(true);
});
it("reveals raw secrets only after explicit toggle when stream mode is off", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
formMode: "raw" as const,
streamMode: false,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
};
render(renderConfig(props), container);
const initialTextarea = container.querySelector("textarea");
expect(initialTextarea?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfig(props), container);
const revealedTextarea = container.querySelector("textarea");
expect(revealedTextarea?.value).toContain("secret-123");
});
it("reveals env values through the peek control instead of CSS-only masking", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
activeSection: "env" as const,
formMode: "form" as const,
streamMode: false,
schema: {
type: "object",
properties: {
env: {
type: "object",
additionalProperties: { type: "string" },
},
},
},
formValue: {
env: {
OPENAI_API_KEY: "secret-123",
},
},
};
render(renderConfig(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input:not(.cfg-input--sm)");
expect(hiddenInput?.value).toBe("");
const peekButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.includes("Peek"),
);
peekButton?.click();
render(renderConfig(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(
".cfg-input:not(.cfg-input--sm)",
);
expect(revealedInput?.value).toBe("secret-123");
});
it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();
@ -204,12 +313,7 @@ describe("config view", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
(option) => option.textContent?.trim(),
);
expect(options).toContain("tag:security");
expect(options).toContain("tag:advanced");
expect(options).toHaveLength(15);
expect(container.querySelectorAll(".config-search__tag-option")).toHaveLength(0);
});
it("updates search query when toggling a tag option", () => {
@ -226,8 +330,7 @@ describe("config view", () => {
const option = container.querySelector<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
expect(option).toBeNull();
expect(onSearchChange).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

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,61 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.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 ? EXTERNAL_LINK_TARGET : nothing}
rel=${item.external ? buildExternalLinkRel() : nothing}
>${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

@ -1,5 +1,31 @@
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
const AUTH_REQUIRED_CODES = 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 AUTH_FAILURE_CODES = new Set<string>([
...AUTH_REQUIRED_CODES,
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 INSECURE_CONTEXT_CODES = new Set<string>([
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
/** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint(
connected: boolean,
@ -14,3 +40,44 @@ export function shouldShowPairingHint(
}
return lastError.toLowerCase().includes("pairing required");
}
export function shouldShowAuthHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return AUTH_FAILURE_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("unauthorized") || lower.includes("connect failed");
}
export function shouldShowAuthRequiredHint(
hasToken: boolean,
hasPassword: boolean,
lastErrorCode?: string | null,
): boolean {
if (lastErrorCode) {
return AUTH_REQUIRED_CODES.has(lastErrorCode);
}
return !hasToken && !hasPassword;
}
export function shouldShowInsecureContextHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return INSECURE_CONTEXT_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("secure context") || lower.includes("device identity required");
}

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,39 +1,95 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { shouldShowPairingHint } from "./overview-hints.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
describe("overview hints", () => {
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
});
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
describe("shouldShowAuthHint", () => {
it("returns true for structured auth failures", () => {
expect(
shouldShowAuthHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
),
).toBe(true);
});
it("falls back to legacy close text when no detail code is present", () => {
expect(shouldShowAuthHint(false, "disconnected (4008): unauthorized")).toBe(true);
});
it("returns false for non-auth errors", () => {
expect(shouldShowAuthHint(false, "disconnected (1006): no reason")).toBe(false);
});
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
describe("shouldShowAuthRequiredHint", () => {
it("returns true for structured auth-required codes", () => {
expect(
shouldShowAuthRequiredHint(true, true, ConnectErrorDetailCodes.AUTH_TOKEN_MISSING),
).toBe(true);
});
it("falls back to missing credentials when detail code is absent", () => {
expect(shouldShowAuthRequiredHint(false, false, null)).toBe(true);
expect(shouldShowAuthRequiredHint(true, false, null)).toBe(false);
});
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
describe("shouldShowInsecureContextHint", () => {
it("returns true for structured device identity errors", () => {
expect(
shouldShowInsecureContextHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
),
).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
it("falls back to legacy close text when detail code is absent", () => {
expect(shouldShowInsecureContextHint(false, "device identity required")).toBe(true);
});
});
});

View File

@ -1,12 +1,29 @@
import { html } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { html, nothing } from "lit";
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 { shouldShowPairingHint } from "./overview-hints.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 {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
connected: boolean;
@ -20,24 +37,41 @@ export type OverviewProps = {
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";
@ -74,38 +108,12 @@ export function renderOverview(props: OverviewProps) {
if (props.connected || !props.lastError) {
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");
if (!authFailed) {
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
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 (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
return html`
<div class="muted" style="margin-top: 8px">
${t("overview.auth.required")}
@ -151,15 +159,7 @@ export function renderOverview(props: OverviewProps) {
if (isSecureContext) {
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 (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
return null;
}
return html`
@ -194,12 +194,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}
@ -220,26 +220,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>
`
}
@ -277,6 +308,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">
@ -321,45 +376,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: "",
}),
);
});
},
},
],
};
});