revert(ui): remove UI portions of mixed commits from main

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Val Alexander
2026-02-22 13:00:34 -06:00
parent 26ab93f0eb
commit 6298698008
74 changed files with 1570 additions and 8326 deletions

View File

@@ -8,18 +8,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<script>
(function () {
var VALID = ["dark", "light", "openknot", "fieldmanual", "openai", "clawdash"];
try {
var s = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") || "{}");
var t = s && s.theme;
if (t && VALID.indexOf(t) !== -1) {
document.documentElement.setAttribute("data-theme", t);
}
} catch (e) {}
})();
</script>
</head>
<body>
<openclaw-app></openclaw-app>

View File

@@ -12,7 +12,6 @@ export const en: TranslationMap = {
na: "n/a",
docs: "Docs",
resources: "Resources",
search: "Search",
},
nav: {
chat: "Chat",
@@ -105,47 +104,6 @@ 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

@@ -12,7 +12,6 @@ export const pt_BR: TranslationMap = {
na: "n/a",
docs: "Docs",
resources: "Recursos",
search: "Pesquisar",
},
nav: {
chat: "Chat",
@@ -107,47 +106,6 @@ 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

@@ -12,7 +12,6 @@ export const zh_CN: TranslationMap = {
na: "不适用",
docs: "文档",
resources: "资源",
search: "搜索",
},
nav: {
chat: "聊天",
@@ -104,47 +103,6 @@ export const zh_CN: TranslationMap = {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。",
},
connection: {
title: "如何连接",
step1: "在主机上启动网关:",
step2: "获取带令牌的仪表盘 URL",
step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。",
step4: "或生成可重复使用的令牌:",
docsHint: "如需远程访问,建议使用 Tailscale Serve。",
docsLink: "查看文档 →",
},
cards: {
cost: "费用",
skills: "技能",
recentSessions: "最近会话",
},
attention: {
title: "注意事项",
},
eventLog: {
title: "事件日志",
},
logTail: {
title: "网关日志",
},
quickActions: {
newSession: "新建会话",
automation: "自动化",
refreshAll: "全部刷新",
terminal: "终端",
},
streamMode: {
active: "流模式 — 数据已隐藏",
disable: "禁用",
},
palette: {
placeholder: "输入命令…",
noResults: "无结果",
},
},
login: {
subtitle: "网关仪表盘",
passwordPlaceholder: "可选",
},
chat: {
disconnected: "已断开与网关的连接。",

View File

@@ -12,7 +12,6 @@ export const zh_TW: TranslationMap = {
na: "不適用",
docs: "文檔",
resources: "資源",
search: "搜尋",
},
nav: {
chat: "聊天",
@@ -104,47 +103,6 @@ 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

@@ -2,5 +2,4 @@
@import "./styles/layout.css";
@import "./styles/layout.mobile.css";
@import "./styles/components.css";
@import "./styles/glass.css";
@import "./styles/config.css";

View File

@@ -1,500 +1,108 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap");
* {
box-sizing: border-box;
}
/* ════════════════════════════════════════════════════════
Theme System — 6 Glassmorphism Themes
════════════════════════════════════════════════════════ */
/* ─── Design Tokens (shared across all themes) ─── */
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
:root {
--icon-size-xs: 0.9rem;
--icon-size-sm: 1.05rem;
--icon-size-md: 1.25rem;
--icon-size-xl: 2.4rem;
/* Background - Warmer dark with depth */
--bg: #12141a;
--bg-accent: #14161d;
--bg-elevated: #1a1d25;
--bg-hover: #262a35;
--bg-muted: #262a35;
--font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-serif: "Playfair Display", Georgia, "Times New Roman", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
/* Card / Surface - More contrast between levels */
--card: #181b22;
--card-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05);
--popover: #181b22;
--popover-foreground: #f4f4f5;
--theme-switch-x: 50%;
--theme-switch-y: 50%;
}
/* Panel */
--panel: #12141a;
--panel-strong: #1a1d25;
--panel-hover: #262a35;
--chrome: rgba(18, 20, 26, 0.95);
--chrome-strong: rgba(18, 20, 26, 0.98);
@media (prefers-reduced-motion: reduce) {
:root {
--clay-duration-fast: 0ms;
--clay-duration-normal: 0ms;
--clay-duration-slow: 0ms;
}
/* Text - Slightly warmer */
--text: #e4e4e7;
--text-strong: #fafafa;
--chat-text: #e4e4e7;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
* {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
/* Border - Subtle but defined */
--border: #27272a;
--border-strong: #3f3f46;
--border-hover: #52525b;
--input: #27272a;
--ring: #ff5c5c;
/* ─── Theme: dark (Home) — Deep-sea Operations Console ─── */
:root,
:root[data-theme="dark"] {
color-scheme: dark;
--vscode-bg: #040810;
--vscode-sidebar: #06090f;
--vscode-panel: #0a0e16;
--vscode-panel-border: rgba(0, 212, 170, 0.08);
--vscode-surface: #0e1420;
--vscode-hover: #121a28;
--vscode-contrast: #020408;
--vscode-text: #d0d8e4;
--vscode-muted: #6e7a8a;
--vscode-subtle: #3a4454;
--vscode-ghost: #0c1018;
--vscode-accent: #ca3a29;
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
--vscode-selection: #3d1418;
--vscode-success: #00d4aa;
--vscode-danger: #ca3a29;
--kn-claw: #ca3a29;
--kn-claw-bright: #fd8e2e;
--kn-claw-dim: rgba(202, 58, 41, 0.12);
--kn-claw-ember: #fb9231;
--kn-claw-deep: #9a2d1f;
--kn-ocean: #09181e;
--kn-ocean-bright: #132a36;
--kn-ocean-mid: #0c1e28;
--kn-ocean-dim: rgba(9, 24, 30, 0.8);
--kn-ocean-deep: #040810;
--kn-silver: #8a9baa;
--kn-silver-bright: #c0cdd6;
--kn-silver-dim: rgba(138, 155, 170, 0.12);
--kn-bioluminescence: #00d4aa;
--kn-warm-dark: #221016;
--kn-void: #221016;
--glass-blur: 8px;
--glass-saturate: 120%;
--glass-bg: rgba(10, 14, 22, 0.82);
--glass-bg-elevated: rgba(14, 20, 32, 0.88);
--glass-border: rgba(0, 212, 170, 0.08);
--glass-border-hover: rgba(202, 58, 41, 0.3);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 212, 170, 0.06);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 212, 170, 0.08);
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
}
/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */
:root[data-theme="light"] {
color-scheme: dark;
--vscode-bg: #0e0c0e;
--vscode-sidebar: #131012;
--vscode-panel: #161214;
--vscode-panel-border: rgba(255, 255, 255, 0.06);
--vscode-surface: #1a1618;
--vscode-hover: #201c1e;
--vscode-contrast: #080608;
--vscode-text: #d5d0cf;
--vscode-muted: #7a7472;
--vscode-subtle: #4a4442;
--vscode-ghost: #1a1616;
--vscode-accent: #ca3a29;
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
--vscode-selection: #3d1418;
--vscode-success: #00d4aa;
--vscode-danger: #ca3a29;
--kn-claw: #ca3a29;
--kn-claw-bright: #fd8e2e;
--kn-claw-dim: rgba(202, 58, 41, 0.12);
--kn-claw-ember: #fb9231;
--kn-claw-deep: #9a2d1f;
--kn-ocean: #0e0c0e;
--kn-ocean-bright: #201c1e;
--kn-ocean-mid: #161214;
--kn-ocean-dim: rgba(14, 12, 14, 0.8);
--kn-ocean-deep: #0e0c0e;
--kn-silver: #8a7e72;
--kn-silver-bright: #c0b4a8;
--kn-silver-dim: rgba(138, 126, 114, 0.12);
--kn-bioluminescence: #00d4aa;
--kn-warm-dark: #1a1416;
--kn-void: #1a1416;
--glass-blur: 0px;
--glass-saturate: 100%;
--glass-bg: rgba(22, 18, 20, 0.95);
--glass-bg-elevated: rgba(26, 22, 24, 0.96);
--glass-border: rgba(255, 255, 255, 0.06);
--glass-border-hover: rgba(202, 58, 41, 0.25);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
}
/* ─── Theme: openknot — Minimalist Premium Noir ─── */
:root[data-theme="openknot"] {
color-scheme: dark;
--vscode-bg: #000000;
--vscode-sidebar: #080808;
--vscode-panel: #0c0c0c;
--vscode-panel-border: rgba(167, 139, 250, 0.08);
--vscode-surface: #111111;
--vscode-hover: #181818;
--vscode-contrast: #000000;
--vscode-text: #e4e4e7;
--vscode-muted: #71717a;
--vscode-subtle: #3f3f46;
--vscode-ghost: #18181b;
--vscode-accent: #a78bfa;
--vscode-accent-alpha: rgba(167, 139, 250, 0.14);
--vscode-selection: #2e1a5e;
--vscode-success: #a78bfa;
--vscode-danger: #a78bfa;
--kn-claw: #a78bfa;
--kn-claw-bright: #c4b5fd;
--kn-claw-dim: rgba(167, 139, 250, 0.12);
--kn-claw-ember: #c4b5fd;
--kn-claw-deep: #7c3aed;
--kn-ocean: #000000;
--kn-ocean-bright: #1a1a1e;
--kn-ocean-mid: #0e0e12;
--kn-ocean-dim: rgba(0, 0, 0, 0.8);
--kn-ocean-deep: #000000;
--kn-silver: #71717a;
--kn-silver-bright: #a1a1aa;
--kn-silver-dim: rgba(113, 113, 122, 0.12);
--kn-bioluminescence: #c4b5fd;
--kn-warm-dark: #18181b;
--kn-void: #18181b;
--glass-blur: 12px;
--glass-saturate: 110%;
--glass-bg: rgba(12, 12, 12, 0.85);
--glass-bg-elevated: rgba(17, 17, 17, 0.9);
--glass-border: rgba(167, 139, 250, 0.08);
--glass-border-hover: rgba(167, 139, 250, 0.3);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5);
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(167, 139, 250, 0.06);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(167, 139, 250, 0.08);
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
}
/* ─── Theme: fieldmanual — Industrial Dossier ─── */
:root[data-theme="fieldmanual"] {
color-scheme: dark;
--vscode-bg: #0e0e0e;
--vscode-sidebar: #121212;
--vscode-panel: #161616;
--vscode-panel-border: rgba(255, 255, 255, 0.1);
--vscode-surface: #1a1a1a;
--vscode-hover: #222222;
--vscode-contrast: #0a0a0a;
--vscode-text: #d4d4d4;
--vscode-muted: #737373;
--vscode-subtle: #404040;
--vscode-ghost: #1a1a1a;
--vscode-accent: #ca3a29;
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
--vscode-selection: #3d1418;
--vscode-success: #61d6ff;
--vscode-danger: #ca3a29;
--kn-claw: #ca3a29;
--kn-claw-bright: #ff6b4a;
--kn-claw-dim: rgba(202, 58, 41, 0.12);
--kn-claw-ember: #ff6b4a;
--kn-claw-deep: #9a2d1f;
--kn-ocean: #0e0e0e;
--kn-ocean-bright: #222222;
--kn-ocean-mid: #161616;
--kn-ocean-dim: rgba(14, 14, 14, 0.8);
--kn-ocean-deep: #0e0e0e;
--kn-silver: #737373;
--kn-silver-bright: #a3a3a3;
--kn-silver-dim: rgba(115, 115, 115, 0.12);
--kn-bioluminescence: #61d6ff;
--kn-warm-dark: #1a1a1a;
--kn-void: #1a1a1a;
--glass-blur: 0px;
--glass-saturate: 100%;
--glass-bg: rgba(22, 22, 22, 0.95);
--glass-bg-elevated: rgba(26, 26, 26, 0.96);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-hover: rgba(202, 58, 41, 0.35);
--glass-highlight: none;
--glass-shadow-sm: none;
--glass-shadow-md: none;
--glass-shadow-lg: none;
--radius-xs: 0px;
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
--radius-full: 0px;
}
/* ─── Theme: openai — Crimson Glassmorphic ─── */
:root[data-theme="openai"] {
color-scheme: dark;
--vscode-bg: #0c0606;
--vscode-sidebar: #100808;
--vscode-panel: #140a0a;
--vscode-panel-border: rgba(202, 58, 41, 0.12);
--vscode-surface: #1a0e0e;
--vscode-hover: #221414;
--vscode-contrast: #060202;
--vscode-text: #e8d8d4;
--vscode-muted: #8a6a64;
--vscode-subtle: #4a3430;
--vscode-ghost: #1a0e0e;
--vscode-accent: #ca3a29;
--vscode-accent-alpha: rgba(202, 58, 41, 0.18);
--vscode-selection: #7d261c;
--vscode-success: #fd8e2e;
--vscode-danger: #ca3a29;
--kn-claw: #ca3a29;
--kn-claw-bright: #ff4e41;
--kn-claw-dim: rgba(202, 58, 41, 0.15);
--kn-claw-ember: #fd8e2e;
--kn-claw-deep: #9a2d1f;
--kn-ocean: #0c0606;
--kn-ocean-bright: #221414;
--kn-ocean-mid: #140a0a;
--kn-ocean-dim: rgba(12, 6, 6, 0.8);
--kn-ocean-deep: #0c0606;
--kn-silver: #8a6a64;
--kn-silver-bright: #c0a49c;
--kn-silver-dim: rgba(138, 106, 100, 0.12);
--kn-bioluminescence: #fd8e2e;
--kn-warm-dark: #221016;
--kn-void: #221016;
--glass-blur: 14px;
--glass-saturate: 130%;
--glass-bg: rgba(20, 10, 10, 0.78);
--glass-bg-elevated: rgba(26, 14, 14, 0.85);
--glass-border: rgba(202, 58, 41, 0.12);
--glass-border-hover: rgba(202, 58, 41, 0.4);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.05);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(202, 58, 41, 0.08);
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(202, 58, 41, 0.1);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(202, 58, 41, 0.12);
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
}
/* ─── Theme: clawdash — Chrome Metallic ─── */
:root[data-theme="clawdash"] {
color-scheme: dark;
--vscode-bg: #050507;
--vscode-sidebar: #08080c;
--vscode-panel: #0c0c10;
--vscode-panel-border: rgba(192, 200, 212, 0.1);
--vscode-surface: #101014;
--vscode-hover: #161620;
--vscode-contrast: #020204;
--vscode-text: #e8ecf0;
--vscode-muted: #8a94a4;
--vscode-subtle: #4a5060;
--vscode-ghost: #1a1a22;
--vscode-accent: #ca3a29;
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
--vscode-selection: #3d1418;
--vscode-success: #00d4aa;
--vscode-danger: #ca3a29;
--kn-claw: #ca3a29;
--kn-claw-bright: #ff4e41;
--kn-claw-dim: rgba(202, 58, 41, 0.12);
--kn-claw-ember: #fd8e2e;
--kn-claw-deep: #9a2d1f;
--kn-ocean: #08080c;
--kn-ocean-bright: #161620;
--kn-ocean-mid: #0c0c10;
--kn-ocean-dim: rgba(8, 8, 12, 0.8);
--kn-ocean-deep: #050507;
--kn-silver: #7a8494;
--kn-silver-bright: #c0c8d4;
--kn-silver-dim: rgba(192, 200, 212, 0.12);
--kn-bioluminescence: #00d4aa;
--kn-warm-dark: #1a1a22;
--kn-void: #1a1a22;
--glass-blur: 16px;
--glass-saturate: 150%;
--glass-bg: rgba(12, 12, 16, 0.8);
--glass-bg-elevated: rgba(16, 16, 20, 0.88);
--glass-border: rgba(192, 200, 212, 0.08);
--glass-border-hover: rgba(192, 200, 212, 0.25);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(192, 200, 212, 0.04);
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(192, 200, 212, 0.06);
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(192, 200, 212, 0.08);
--radius-xs: 3px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-full: 9999px;
}
/* ─── Semantic Alias Layer ───
Maps foundation vars to the short names used throughout
component CSS, so themes work without per-component overrides. */
:root,
:root[data-theme="dark"],
:root[data-theme="light"],
:root[data-theme="openknot"],
:root[data-theme="fieldmanual"],
:root[data-theme="openai"],
:root[data-theme="clawdash"] {
/* Core surfaces */
--bg: var(--vscode-bg);
--bg-accent: var(--vscode-sidebar);
--bg-elevated: var(--vscode-surface);
--bg-hover: var(--vscode-hover);
--bg-muted: var(--vscode-sidebar);
--bg-content: var(--vscode-bg);
/* Card/popover surfaces */
--card: var(--vscode-panel);
--card-foreground: var(--vscode-text);
--card-highlight: rgba(255, 255, 255, 0.04);
--popover: var(--vscode-panel);
--popover-foreground: var(--vscode-text);
/* Panel/chrome surfaces */
--panel: var(--vscode-sidebar);
--panel-strong: var(--vscode-panel);
--panel-hover: var(--vscode-hover);
--chrome: var(--glass-bg);
--chrome-strong: var(--glass-bg-elevated);
/* Typography */
--text: var(--vscode-text);
--text-strong: var(--vscode-text);
--chat-text: var(--vscode-text);
--muted: var(--vscode-muted);
--muted-strong: var(--vscode-subtle);
--muted-foreground: var(--vscode-muted);
/* Borders + controls */
--border: var(--glass-border);
--border-strong: var(--glass-border-hover);
--border-hover: var(--glass-border-hover);
--input: var(--glass-border);
--ring: var(--vscode-accent);
/* Accent */
--accent: var(--vscode-accent);
--accent-strong: var(--kn-claw-deep);
--accent-hover: var(--kn-claw-bright);
--accent-muted: var(--vscode-accent);
--accent-subtle: var(--vscode-accent-alpha);
/* Accent - Punchy signature red */
--accent: #ff5c5c;
--accent-hover: #ff7070;
--accent-muted: #ff5c5c;
--accent-subtle: rgba(255, 92, 92, 0.15);
--accent-foreground: #fafafa;
--accent-glow: var(--kn-claw-dim);
--accent-soft: var(--vscode-accent-alpha);
--primary: var(--vscode-accent);
--accent-glow: rgba(255, 92, 92, 0.25);
--primary: #ff5c5c;
--primary-foreground: #ffffff;
/* Secondary */
--secondary: var(--vscode-sidebar);
--secondary-foreground: var(--vscode-text);
--accent-2: var(--kn-bioluminescence);
--accent-2-muted: var(--kn-silver);
--accent-2-subtle: var(--kn-silver-dim);
/* Secondary - Teal accent for variety */
--secondary: #1e2028;
--secondary-foreground: #f4f4f5;
--accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic */
--ok: var(--vscode-success);
--ok-muted: var(--vscode-success);
--ok-subtle: var(--kn-silver-dim);
--destructive: var(--vscode-danger);
/* Semantic - More saturated */
--ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: rgba(34, 197, 94, 0.12);
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--warn: var(--kn-claw-ember);
--warn-muted: var(--kn-claw-ember);
--warn-subtle: var(--kn-claw-dim);
--danger: var(--vscode-danger);
--danger-muted: var(--vscode-danger);
--danger-subtle: var(--kn-claw-dim);
--warn: #f59e0b;
--warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: rgba(245, 158, 11, 0.12);
--danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6;
--success: var(--vscode-success);
/* Focus */
--focus: var(--kn-claw-dim);
--focus-offset-color: var(--bg);
--focus-ring-width: 2px;
--focus-ring-offset-width: 2px;
--focus-ring-color: var(--vscode-accent);
--focus-ring:
0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color),
0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color);
--focus-glow:
0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color),
0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color),
0 0 18px var(--accent-glow);
/* Focus - With glow */
--focus: rgba(255, 92, 92, 0.25);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
/* Grid */
--grid-line: rgba(255, 255, 255, 0.04);
/* Shadows */
--shadow-sm: var(--glass-shadow-sm);
--shadow-md: var(--glass-shadow-md);
--shadow-lg: var(--glass-shadow-lg);
--shadow-xl: var(--glass-shadow-lg);
/* Theme transition */
--theme-switch-x: 50%;
--theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */
--mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display:
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
--shadow-glow: 0 0 30px var(--accent-glow);
/* Radii — aliased from foundation */
--radius: var(--radius-md);
/* Radii - Slightly larger for friendlier feel */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
--radius: 8px;
/* Timing */
/* Transitions - Snappy but smooth */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -502,68 +110,88 @@
--duration-normal: 200ms;
--duration-slow: 350ms;
/* Typography stacks */
--mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Clay compat layer (dashboard-lit components) */
--clay-bg: var(--vscode-bg);
--clay-bg-card: var(--vscode-panel);
--clay-bg-elevated: var(--vscode-surface);
--clay-bg-button: var(--vscode-hover);
--clay-bg-interactive: var(--vscode-accent-alpha);
--clay-bg-pressed: var(--vscode-selection);
--clay-bg-scrim: rgba(0, 0, 0, 0.6);
--clay-border-color: var(--glass-border);
--clay-border-subtle: var(--vscode-panel-border);
--clay-shadow: var(--glass-shadow-sm);
--clay-shadow-elevated: var(--glass-shadow-md);
--clay-shadow-pressed: var(--glass-shadow-sm);
--clay-shadow-subtle: var(--glass-shadow-sm);
--clay-radius-sm: var(--radius-sm);
--clay-radius: var(--radius-md);
--clay-radius-md: var(--radius-md);
--clay-radius-lg: var(--radius-lg);
--clay-radius-xl: var(--radius-xl);
--clay-radius-pill: var(--radius-full);
--clay-duration-fast: 150ms;
--clay-duration-normal: 250ms;
--clay-duration-slow: 400ms;
--clay-easing: cubic-bezier(0.16, 1, 0.3, 1);
/* Layout semantic tokens */
--topbar-bg: var(--vscode-sidebar);
--topbar-shadow: none;
--topbar-border: 1px solid var(--glass-border);
--topbar-title-color: var(--vscode-text);
--topbar-title-weight: 600;
--sidebar-bg: var(--vscode-sidebar);
--sidebar-border: none;
--sidebar-nav-inactive: var(--vscode-muted);
--sidebar-nav-active-bg: var(--vscode-accent-alpha);
--sidebar-nav-active-bar: 3px solid var(--vscode-accent);
--agent-header-bg: var(--vscode-panel);
--agent-header-border: 1px solid var(--glass-border);
--agent-tab-active-bg: var(--vscode-accent-alpha);
--agent-tab-hover-bg: var(--vscode-accent-alpha);
color-scheme: dark;
}
/* ─── Accessibility: High Contrast ─── */
/* Light theme - Clean with subtle warmth */
:root[data-theme="light"] {
--bg: #fafafa;
--bg-accent: #f5f5f5;
--bg-elevated: #ffffff;
--bg-hover: #f0f0f0;
--bg-muted: #f0f0f0;
--bg-content: #f5f5f5;
@media (prefers-contrast: more) {
:root {
--glass-shadow-sm: 0 0 0 2px var(--vscode-text);
--glass-shadow-md: 0 0 0 2px var(--vscode-text);
--glass-shadow-lg: 0 0 0 2px var(--vscode-text);
--glass-border: rgba(255, 255, 255, 0.3);
}
--card: #ffffff;
--card-foreground: #18181b;
--card-highlight: rgba(0, 0, 0, 0.03);
--popover: #ffffff;
--popover-foreground: #18181b;
--panel: #fafafa;
--panel-strong: #f5f5f5;
--panel-hover: #ebebeb;
--chrome: rgba(250, 250, 250, 0.95);
--chrome-strong: rgba(250, 250, 250, 0.98);
--text: #3f3f46;
--text-strong: #18181b;
--chat-text: #3f3f46;
--muted: #71717a;
--muted-strong: #52525b;
--muted-foreground: #71717a;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--border-hover: #a1a1aa;
--input: #e4e4e7;
--accent: #dc2626;
--accent-hover: #ef4444;
--accent-muted: #dc2626;
--accent-subtle: rgba(220, 38, 38, 0.12);
--accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15);
--primary: #dc2626;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #3f3f46;
--accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12);
--ok: #16a34a;
--ok-muted: rgba(22, 163, 74, 0.75);
--ok-subtle: rgba(22, 163, 74, 0.1);
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--warn: #d97706;
--warn-muted: rgba(217, 119, 6, 0.75);
--warn-subtle: rgba(217, 119, 6, 0.1);
--danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.1);
--info: #2563eb;
--focus: rgba(220, 38, 38, 0.2);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
--grid-line: rgba(0, 0, 0, 0.05);
/* Light shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
--shadow-glow: 0 0 24px var(--accent-glow);
color-scheme: light;
}
/* ════════════════════════════════════════════════════════
Base Styles
════════════════════════════════════════════════════════ */
* {
box-sizing: border-box;
}
html,
body {
@@ -572,8 +200,8 @@ body {
body {
margin: 0;
font: 400 15px/1.55 var(--font-body);
letter-spacing: -0.01em;
font: 400 14px/1.55 var(--font-body);
letter-spacing: -0.02em;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
@@ -661,170 +289,7 @@ select {
background: var(--border-strong);
}
/* ════════════════════════════════════════════════════════
Theme-Specific Decorative Effects
════════════════════════════════════════════════════════ */
/* ─── Dark — Star field + ambient gradients ─── */
:root[data-theme="dark"] body {
background:
radial-gradient(ellipse 80% 50% at 50% -5%, rgba(0, 212, 170, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 60% 20%, rgba(202, 58, 41, 0.04) 0%, transparent 50%),
var(--bg);
}
@keyframes star-twinkle {
0% {
opacity: 0.35;
}
100% {
opacity: 0.55;
}
}
:root[data-theme="dark"] body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.45;
animation: star-twinkle 5s ease-in-out infinite alternate;
box-shadow:
120px 40px 0 0.4px rgba(0, 212, 170, 0.5),
340px 90px 0 0.3px rgba(0, 212, 170, 0.3),
580px 60px 0 0.5px rgba(0, 212, 170, 0.6),
800px 130px 0 0.3px rgba(0, 212, 170, 0.4),
1050px 50px 0 0.4px rgba(0, 212, 170, 0.3),
90px 200px 0 0.5px rgba(0, 212, 170, 0.4),
470px 220px 0 0.4px rgba(0, 212, 170, 0.5),
900px 250px 0 0.5px rgba(0, 212, 170, 0.6),
200px 420px 0 0.5px rgba(0, 212, 170, 0.5),
640px 450px 0 0.4px rgba(0, 212, 170, 0.4),
1060px 380px 0 0.5px rgba(0, 212, 170, 0.3),
380px 580px 0 0.3px rgba(0, 212, 170, 0.4),
780px 570px 0 0.3px rgba(0, 212, 170, 0.5),
110px 680px 0 0.5px rgba(0, 212, 170, 0.4),
520px 660px 0 0.4px rgba(0, 212, 170, 0.5);
}
/* ─── openknot — Lavender stars ─── */
:root[data-theme="openknot"] body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
opacity: 0.35;
animation: star-twinkle 8s ease-in-out infinite alternate;
box-shadow:
120px 40px 0 0.4px rgba(196, 181, 253, 0.5),
340px 90px 0 0.3px rgba(196, 181, 253, 0.3),
580px 60px 0 0.5px rgba(196, 181, 253, 0.6),
800px 130px 0 0.3px rgba(196, 181, 253, 0.4),
90px 200px 0 0.5px rgba(196, 181, 253, 0.4),
470px 220px 0 0.4px rgba(196, 181, 253, 0.5),
900px 250px 0 0.5px rgba(196, 181, 253, 0.6),
200px 420px 0 0.5px rgba(196, 181, 253, 0.5),
640px 450px 0 0.4px rgba(196, 181, 253, 0.4),
380px 580px 0 0.3px rgba(196, 181, 253, 0.4),
780px 570px 0 0.3px rgba(196, 181, 253, 0.5),
520px 660px 0 0.4px rgba(196, 181, 253, 0.5);
}
/* ─── fieldmanual — Industrial Dossier Overrides ─── */
:root[data-theme="fieldmanual"] .page-title,
:root[data-theme="fieldmanual"] .panel-title,
:root[data-theme="fieldmanual"] .agent-chat__welcome h2 {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
}
:root[data-theme="fieldmanual"] .sidebar-brand__title {
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.04em;
}
:root[data-theme="fieldmanual"] .glass-dashboard-card,
:root[data-theme="fieldmanual"] .stat-card,
:root[data-theme="fieldmanual"] .agent-chat__starter {
border-style: dashed;
}
:root[data-theme="fieldmanual"] .sidebar {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--vscode-sidebar);
}
:root[data-theme="fieldmanual"] .glass-dashboard-card {
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--vscode-panel);
}
:root[data-theme="fieldmanual"] body::after {
display: none;
}
/* ─── openai — Crimson atmosphere ─── */
:root[data-theme="openai"] body {
background:
radial-gradient(ellipse 80% 50% at 50% -5%, rgba(202, 58, 41, 0.12) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 60% 20%, rgba(253, 142, 46, 0.04) 0%, transparent 50%),
var(--bg);
}
:root[data-theme="openai"] body::after {
display: none;
}
/* ─── clawdash — Chrome Metallic Overrides ─── */
:root[data-theme="clawdash"] body {
background:
radial-gradient(ellipse 80% 50% at 40% -10%, rgba(192, 200, 212, 0.06) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 70% 30%, rgba(202, 58, 41, 0.04) 0%, transparent 50%),
var(--bg);
}
:root[data-theme="clawdash"] body::after {
display: none;
}
:root[data-theme="clawdash"] .nav-item--active {
border-image: linear-gradient(to bottom, var(--kn-silver-bright), var(--kn-claw)) 1;
border-image-slice: 1;
}
/* ─── High Contrast Overrides (all themes) ─── */
@media (prefers-contrast: more) {
.topbar,
.sidebar,
.nav-item--active,
.stat-card,
.callout,
.pill,
pre,
input,
button {
box-shadow: 0 0 0 2px var(--text) !important;
border-width: 1.5px;
}
}
/* ════════════════════════════════════════════════════════
Animations
════════════════════════════════════════════════════════ */
/* Animations - Polished with spring feel */
@keyframes rise {
from {
opacity: 0;
@@ -896,15 +361,6 @@ select {
}
}
@keyframes chrome-shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* Stagger animation delays for grouped elements */
.stagger-1 {
animation-delay: 0ms;

View File

@@ -3,4 +3,3 @@
@import "./chat/grouped.css";
@import "./chat/tool-cards.css";
@import "./chat/sidebar.css";
@import "./chat/agent-chat.css";

File diff suppressed because it is too large Load Diff

View File

@@ -83,15 +83,14 @@
/* Avatar Styles */
.chat-avatar {
width: 38px;
height: 38px;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: color-mix(in srgb, var(--panel-strong) 95%, transparent);
width: 40px;
height: 40px;
border-radius: 8px;
background: var(--panel-strong);
display: grid;
place-items: center;
font-weight: 600;
font-size: 13px;
font-size: 14px;
flex-shrink: 0;
align-self: flex-end; /* Align with last message in group */
margin-bottom: 4px; /* Optical alignment */
@@ -128,15 +127,14 @@ img.chat-avatar {
.chat-bubble {
position: relative;
display: inline-block;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: color-mix(in srgb, var(--card) 97%, transparent);
border: 1px solid transparent;
background: var(--card);
border-radius: var(--radius-lg);
padding: 10px 14px;
box-shadow: inset 0 1px 0 var(--card-highlight);
box-shadow: none;
transition:
background 150ms ease-out,
border-color 150ms ease-out,
box-shadow 150ms ease-out;
border-color 150ms ease-out;
max-width: 100%;
word-wrap: break-word;
}
@@ -149,8 +147,8 @@ img.chat-avatar {
position: absolute;
top: 6px;
right: 8px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: color-mix(in srgb, var(--bg) 94%, transparent);
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
border-radius: var(--radius-md);
padding: 4px 6px;
@@ -161,8 +159,7 @@ img.chat-avatar {
pointer-events: none;
transition:
opacity 120ms ease-out,
background 120ms ease-out,
border-color 120ms ease-out;
background 120ms ease-out;
}
.chat-copy-btn__icon {
@@ -209,7 +206,6 @@ img.chat-avatar {
.chat-copy-btn:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.chat-copy-btn[data-copying="1"] {
@@ -247,20 +243,29 @@ img.chat-avatar {
}
}
/* Light mode: restore borders */
:root[data-theme="light"] .chat-bubble {
border-color: var(--border);
box-shadow: inset 0 1px 0 var(--card-highlight);
}
.chat-bubble:hover {
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover));
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
background: var(--bg-hover);
}
/* User bubbles have different styling */
.chat-group.user .chat-bubble {
background: color-mix(in srgb, var(--accent-subtle) 85%, var(--secondary));
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
background: var(--accent-subtle);
border-color: transparent;
}
:root[data-theme="light"] .chat-group.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2);
background: rgba(251, 146, 60, 0.12);
}
.chat-group.user .chat-bubble:hover {
background: var(--danger-subtle);
background: rgba(255, 77, 77, 0.15);
}
/* Streaming animation */
@@ -293,59 +298,3 @@ img.chat-avatar {
transform: translateY(0);
}
}
/* Delete button (appears on hover in group footer) */
.chat-group-delete {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: var(--radius-sm);
color: var(--muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 120ms ease-out,
color 120ms ease-out,
background 120ms ease-out;
margin-left: auto;
}
.chat-group-delete svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-group:hover .chat-group-delete {
opacity: 0.5;
pointer-events: auto;
}
.chat-group-delete:hover {
opacity: 1 !important;
color: var(--danger);
background: var(--danger-subtle);
}
.chat-group-delete:focus-visible {
opacity: 1;
pointer-events: auto;
outline: 2px solid var(--accent);
outline-offset: 1px;
}
@media (hover: none) {
.chat-group-delete {
opacity: 0.5;
pointer-events: auto;
}
}

View File

@@ -52,15 +52,11 @@
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto;
overflow-x: hidden;
padding: 14px 8px;
padding: 12px 4px;
margin: 0 -4px;
min-height: 0; /* Allow shrinking for flex scroll behavior */
border-radius: var(--radius-lg);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--panel) 72%, transparent),
transparent
);
border-radius: 12px;
background: transparent;
}
/* Focus mode exit button */
@@ -115,22 +111,20 @@
font-size: 13px;
font-family: var(--font-body);
color: var(--text);
background: color-mix(in srgb, var(--panel-strong) 92%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 999px;
cursor: pointer;
white-space: nowrap;
z-index: 10;
transition:
background 150ms ease-out,
border-color 150ms ease-out,
box-shadow 150ms ease-out;
border-color 150ms ease-out;
}
.chat-new-messages:hover {
background: var(--panel);
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
box-shadow: var(--shadow-sm);
border-color: var(--accent);
}
.chat-new-messages svg {
@@ -153,9 +147,8 @@
flex-direction: column;
gap: 12px;
margin-top: auto; /* Push to bottom of flex container */
padding: 14px 6px 6px;
background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%);
backdrop-filter: blur(4px);
padding: 12px 4px 4px;
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
z-index: 10;
}
@@ -225,6 +218,21 @@
stroke-width: 2px;
}
/* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments {
background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1);
}
:root[data-theme="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15);
background: #fff;
}
:root[data-theme="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6);
}
/* Message images (sent images displayed in chat) */
.chat-message-images {
display: flex;
@@ -259,6 +267,10 @@
flex: 1;
}
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}
.chat-compose__field {
flex: 1 1 auto;
min-width: 0;
@@ -278,16 +290,13 @@
min-height: 40px;
max-height: 150px;
padding: 9px 12px;
border-radius: var(--radius-md);
border-radius: 8px;
overflow-y: auto;
resize: none;
white-space: pre-wrap;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
border: 1px solid color-mix(in srgb, var(--input) 92%, transparent);
background: color-mix(in srgb, var(--card) 98%, transparent);
box-shadow: inset 0 1px 0 var(--card-highlight);
}
.chat-compose__field textarea:disabled {
@@ -342,22 +351,25 @@
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: color-mix(in srgb, var(--secondary) 85%, transparent);
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
}
/* Controls separator */
.chat-controls__separator {
color: var(--border);
color: rgba(255, 255, 255, 0.4);
font-size: 18px;
margin: 0 8px;
font-weight: 300;
}
:root[data-theme="light"] .chat-controls__separator {
color: rgba(16, 24, 40, 0.3);
}
.btn--icon:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
/* Light theme icon button overrides */
@@ -374,6 +386,27 @@
color: var(--text);
}
/* Light theme icon button overrides */
:root[data-theme="light"] .btn--icon {
background: #ffffff;
border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted);
}
:root[data-theme="light"] .btn--icon:hover {
background: #ffffff;
border-color: var(--border-strong);
color: var(--text);
}
:root[data-theme="light"] .chat-controls .btn--icon.active {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent);
box-shadow: 0 0 0 1px var(--accent-subtle);
}
.btn--icon svg {
display: block;
width: 18px;
@@ -399,9 +432,15 @@
gap: 4px;
font-size: 12px;
padding: 4px 10px;
background: color-mix(in srgb, var(--secondary) 90%, transparent);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
border: 1px solid var(--border);
}
/* Light theme thinking indicator override */
:root[data-theme="light"] .chat-controls__thinking {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(16, 24, 40, 0.15);
}
@media (max-width: 640px) {

View File

@@ -19,12 +19,11 @@
.chat-sidebar {
flex: 1;
min-width: 300px;
border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slide-in 200ms ease-out;
background: color-mix(in srgb, var(--panel) 94%, transparent);
}
@keyframes slide-in {
@@ -51,13 +50,12 @@
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--panel) 95%, transparent);
backdrop-filter: blur(6px);
background: var(--panel);
}
/* Smaller close button for sidebar */
@@ -81,13 +79,12 @@
.sidebar-markdown {
font-size: 14px;
line-height: 1.6;
line-height: 1.5;
}
.sidebar-markdown pre {
background: color-mix(in srgb, var(--secondary) 90%, transparent);
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
padding: 12px;
overflow-x: auto;
}

View File

@@ -5,12 +5,17 @@
.chat-thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px dashed color-mix(in srgb, var(--border) 84%, transparent);
background: color-mix(in srgb, var(--secondary) 75%, transparent);
border-radius: 10px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 12px;
line-height: 1.45;
line-height: 1.4;
}
:root[data-theme="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.25);
background: rgba(16, 24, 40, 0.04);
}
.chat-text {
@@ -52,16 +57,14 @@
}
.chat-text :where(:not(pre) > code) {
background: color-mix(in srgb, var(--secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
padding: 0.15em 0.42em;
border-radius: 5px;
background: rgba(0, 0, 0, 0.15);
padding: 0.15em 0.4em;
border-radius: 4px;
}
.chat-text :where(pre) {
background: color-mix(in srgb, var(--secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: var(--radius-md);
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
}
@@ -71,50 +74,12 @@
padding: 0;
}
/* Collapsed JSON code blocks */
.chat-text :where(details.json-collapse) {
background: color-mix(in srgb, var(--secondary) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: var(--radius-md);
}
.chat-text :where(details.json-collapse > summary) {
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
font-family: var(--mono);
user-select: none;
list-style: none;
}
.chat-text :where(details.json-collapse > summary::-webkit-details-marker) {
display: none;
}
.chat-text :where(details.json-collapse > summary::before) {
content: "▸ ";
}
.chat-text :where(details.json-collapse[open] > summary::before) {
content: "▾ ";
}
.chat-text :where(details.json-collapse > pre) {
background: none;
border: none;
border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
border-radius: 0;
margin: 0;
}
.chat-text :where(blockquote) {
border-left: 3px solid color-mix(in srgb, var(--border-strong) 88%, transparent);
border-left: 3px solid var(--border-strong);
padding-left: 12px;
margin-left: 0;
color: var(--muted);
background: color-mix(in srgb, var(--secondary) 78%, transparent);
background: rgba(255, 255, 255, 0.02);
padding: 8px 12px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
@@ -122,12 +87,34 @@
.chat-text :where(blockquote blockquote) {
margin-top: 8px;
border-left-color: var(--border-hover);
background: color-mix(in srgb, var(--secondary) 55%, transparent);
background: rgba(255, 255, 255, 0.03);
}
.chat-text :where(blockquote blockquote blockquote) {
border-left-color: var(--muted-strong);
background: color-mix(in srgb, var(--secondary) 60%, transparent);
background: rgba(255, 255, 255, 0.04);
}
:root[data-theme="light"] .chat-text :where(blockquote) {
background: rgba(0, 0, 0, 0.03);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
background: rgba(0, 0, 0, 0.05);
}
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
background: rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:root[data-theme="light"] .chat-text :where(pre) {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.chat-text :where(hr) {

View File

@@ -1,15 +1,14 @@
/* Tool Card Styles */
.chat-tool-card {
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
border-radius: var(--radius-md);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 8px;
background: color-mix(in srgb, var(--card) 97%, transparent);
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition:
border-color 150ms ease-out,
background 150ms ease-out,
box-shadow 150ms ease-out;
background 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */
max-height: 120px;
overflow: hidden;
@@ -17,8 +16,7 @@
.chat-tool-card:hover {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover));
box-shadow: var(--shadow-sm);
background: var(--bg-hover);
}
/* First tool card in a group - no top margin */
@@ -130,13 +128,13 @@
color: var(--muted);
margin-top: 8px;
padding: 8px 10px;
background: color-mix(in srgb, var(--secondary) 92%, transparent);
background: var(--secondary);
border-radius: var(--radius-md);
white-space: pre-wrap;
overflow: hidden;
max-height: 44px;
line-height: 1.4;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border: 1px solid var(--border);
}
.chat-tool-card--clickable:hover .chat-tool-card__preview {
@@ -150,18 +148,16 @@
color: var(--text);
margin-top: 6px;
padding: 6px 8px;
background: color-mix(in srgb, var(--secondary) 92%, transparent);
background: var(--secondary);
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
white-space: pre-wrap;
word-break: break-word;
}
/* Reading Indicator */
.chat-reading-indicator {
background: color-mix(in srgb, var(--secondary) 70%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
border-radius: var(--radius-md);
background: transparent;
border: 1px solid var(--border);
padding: 12px;
display: inline-flex;
}
@@ -204,176 +200,3 @@
transform: scale(1);
}
}
/* ===========================================
Collapsible Tool Cards
=========================================== */
.chat-tools-collapse {
margin-top: 8px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--card) 94%, transparent);
overflow: hidden;
}
.chat-tools-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--muted);
user-select: none;
list-style: none;
transition:
color 150ms ease,
background 150ms ease;
}
.chat-tools-summary::-webkit-details-marker {
display: none;
}
.chat-tools-summary::before {
content: "▸";
font-size: 10px;
flex-shrink: 0;
transition: transform 150ms ease;
}
.chat-tools-collapse[open] > .chat-tools-summary::before {
transform: rotate(90deg);
}
.chat-tools-summary:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
}
.chat-tools-summary__icon {
display: inline-flex;
align-items: center;
width: 14px;
height: 14px;
color: var(--accent);
opacity: 0.7;
flex-shrink: 0;
}
.chat-tools-summary__icon svg {
width: 14px;
height: 14px;
}
.chat-tools-summary__count {
font-weight: 600;
color: var(--text);
}
.chat-tools-summary__names {
color: var(--muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tools-collapse__body {
padding: 4px 12px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.chat-tools-collapse__body .chat-tool-card:first-child {
margin-top: 8px;
}
/* ===========================================
Collapsible JSON Block
=========================================== */
.chat-json-collapse {
margin-top: 4px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 60%, transparent);
overflow: hidden;
}
.chat-json-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
user-select: none;
list-style: none;
transition:
color 150ms ease,
background 150ms ease;
}
.chat-json-summary::-webkit-details-marker {
display: none;
}
.chat-json-summary::before {
content: "▸";
font-size: 10px;
flex-shrink: 0;
transition: transform 150ms ease;
}
.chat-json-collapse[open] > .chat-json-summary::before {
transform: rotate(90deg);
}
.chat-json-summary:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
}
.chat-json-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.4;
flex-shrink: 0;
}
.chat-json-label {
font-family: var(--mono);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-json-content {
margin: 0;
padding: 10px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
color: var(--text);
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.chat-json-content code {
font-family: inherit;
font-size: inherit;
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,10 @@
overflow: hidden;
}
:root[data-theme="light"] .config-sidebar {
background: var(--bg-hover);
}
.config-sidebar__header {
display: flex;
align-items: center;
@@ -37,7 +41,7 @@
.config-sidebar__title {
font-weight: 600;
font-size: 15px;
font-size: 14px;
letter-spacing: -0.01em;
}
@@ -71,7 +75,7 @@
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
font-size: 14px;
font-size: 13px;
outline: none;
transition:
border-color var(--duration-fast) ease,
@@ -89,6 +93,14 @@
background: var(--bg-hover);
}
:root[data-theme="light"] .config-search__input {
background: white;
}
:root[data-theme="light"] .config-search__input:focus {
background: white;
}
.config-search__clear {
position: absolute;
right: 22px;
@@ -133,7 +145,7 @@
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
font-size: 14px;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
@@ -147,6 +159,10 @@
color: var(--text);
}
:root[data-theme="light"] .config-nav__item:hover {
background: rgba(0, 0, 0, 0.04);
}
.config-nav__item.active {
background: var(--accent-subtle);
color: var(--accent);
@@ -190,6 +206,10 @@
border: 1px solid var(--border);
}
:root[data-theme="light"] .config-mode-toggle {
background: white;
}
.config-mode-toggle__btn {
flex: 1;
padding: 9px 14px;
@@ -240,6 +260,10 @@
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .config-actions {
background: var(--bg-hover);
}
.config-actions__left,
.config-actions__right {
display: flex;
@@ -251,7 +275,7 @@
padding: 6px 14px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
border: 1px solid rgba(255, 77, 77, 0.3);
color: var(--accent);
font-size: 12px;
font-weight: 600;
@@ -265,7 +289,7 @@
/* Diff Panel */
.config-diff {
margin: 18px 22px 0;
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
border: 1px solid rgba(255, 77, 77, 0.25);
border-radius: var(--radius-lg);
background: var(--accent-subtle);
overflow: hidden;
@@ -319,6 +343,10 @@
font-family: var(--mono);
}
:root[data-theme="light"] .config-diff__item {
background: white;
}
.config-diff__path {
font-weight: 600;
color: var(--text);
@@ -356,6 +384,10 @@
background: var(--bg-accent);
}
:root[data-theme="light"] .config-section-hero {
background: var(--bg-hover);
}
.config-section-hero__icon {
width: 30px;
height: 30px;
@@ -379,7 +411,7 @@
}
.config-section-hero__title {
font-size: 17px;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
@@ -388,7 +420,7 @@
}
.config-section-hero__desc {
font-size: 14px;
font-size: 13px;
color: var(--muted);
}
@@ -402,6 +434,10 @@
overflow-x: auto;
}
:root[data-theme="light"] .config-subnav {
background: var(--bg-hover);
}
.config-subnav__item {
border: 1px solid transparent;
border-radius: var(--radius-full);
@@ -418,6 +454,10 @@
white-space: nowrap;
}
:root[data-theme="light"] .config-subnav__item {
background: white;
}
.config-subnav__item:hover {
color: var(--text);
border-color: var(--border);
@@ -511,6 +551,10 @@
border-color: var(--border-strong);
}
:root[data-theme="light"] .config-section-card {
background: white;
}
.config-section-card__header {
display: flex;
align-items: flex-start;
@@ -520,6 +564,10 @@
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .config-section-card__header {
background: var(--bg-hover);
}
.config-section-card__icon {
width: 34px;
height: 34px;
@@ -539,7 +587,7 @@
.config-section-card__title {
margin: 0;
font-size: 18px;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
@@ -549,7 +597,7 @@
.config-section-card__desc {
margin: 5px 0 0;
font-size: 14px;
font-size: 13px;
color: var(--muted);
line-height: 1.45;
}
@@ -576,23 +624,23 @@
padding: 14px;
border-radius: var(--radius-md);
background: var(--danger-subtle);
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.cfg-field__label {
font-size: 14px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.cfg-field__help {
font-size: 13px;
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.cfg-field__error {
font-size: 13px;
font-size: 12px;
color: var(--danger);
}
@@ -627,6 +675,14 @@
background: var(--bg-hover);
}
:root[data-theme="light"] .cfg-input {
background: white;
}
:root[data-theme="light"] .cfg-input:focus {
background: white;
}
.cfg-input--sm {
padding: 9px 12px;
font-size: 13px;
@@ -677,6 +733,10 @@
box-shadow: var(--focus-ring);
}
:root[data-theme="light"] .cfg-textarea {
background: white;
}
.cfg-textarea--sm {
padding: 10px 12px;
font-size: 12px;
@@ -691,6 +751,10 @@
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-number {
background: white;
}
.cfg-number__btn {
width: 44px;
border: none;
@@ -711,6 +775,14 @@
cursor: not-allowed;
}
:root[data-theme="light"] .cfg-number__btn {
background: var(--bg-hover);
}
:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) {
background: var(--border);
}
.cfg-number__input {
width: 85px;
padding: 11px;
@@ -753,6 +825,10 @@
box-shadow: var(--focus-ring);
}
:root[data-theme="light"] .cfg-select {
background-color: white;
}
/* Segmented Control */
.cfg-segmented {
display: inline-flex;
@@ -762,13 +838,17 @@
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-segmented {
background: var(--bg-hover);
}
.cfg-segmented__btn {
padding: 9px 18px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font-size: 14px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition:
@@ -818,6 +898,14 @@
cursor: not-allowed;
}
:root[data-theme="light"] .cfg-toggle-row {
background: white;
}
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
background: var(--bg-hover);
}
.cfg-toggle-row__content {
flex: 1;
min-width: 0;
@@ -825,7 +913,7 @@
.cfg-toggle-row__label {
display: block;
font-size: 15px;
font-size: 14px;
font-weight: 500;
color: var(--text);
}
@@ -833,7 +921,7 @@
.cfg-toggle-row__help {
display: block;
margin-top: 3px;
font-size: 13px;
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
@@ -864,6 +952,10 @@
border-color var(--duration-normal) ease;
}
:root[data-theme="light"] .cfg-toggle__track {
background: var(--border);
}
.cfg-toggle__track::after {
content: "";
position: absolute;
@@ -881,7 +973,7 @@
.cfg-toggle input:checked + .cfg-toggle__track {
background: var(--ok-subtle);
border-color: color-mix(in srgb, var(--ok) 40%, transparent);
border-color: rgba(34, 197, 94, 0.4);
}
.cfg-toggle input:checked + .cfg-toggle__track::after {
@@ -901,6 +993,10 @@
overflow: hidden;
}
:root[data-theme="light"] .cfg-object {
background: white;
}
.cfg-object__header {
display: flex;
align-items: center;
@@ -970,6 +1066,10 @@
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .cfg-array__header {
background: var(--bg-hover);
}
.cfg-array__label {
flex: 1;
font-size: 14px;
@@ -985,6 +1085,10 @@
border-radius: var(--radius-full);
}
:root[data-theme="light"] .cfg-array__count {
background: white;
}
.cfg-array__add {
display: inline-flex;
align-items: center;
@@ -1052,6 +1156,10 @@
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .cfg-array__item-header {
background: var(--bg-hover);
}
.cfg-array__item-index {
font-size: 11px;
font-weight: 600;
@@ -1112,6 +1220,10 @@
border-bottom: 1px solid var(--border);
}
:root[data-theme="light"] .cfg-map__header {
background: var(--bg-hover);
}
.cfg-map__label {
font-size: 13px;
font-weight: 600;
@@ -1208,7 +1320,7 @@
}
.pill--ok {
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
border-color: rgba(34, 197, 94, 0.35);
color: var(--ok);
}
@@ -1332,85 +1444,3 @@
min-width: 70px;
}
}
/* ===========================================
Environment Values Blur + Peek Toggle
=========================================== */
.config-env-values--blurred .cfg-input,
.config-env-values--blurred .cfg-number__input,
.config-env-values--blurred textarea {
color: transparent;
text-shadow: 0 0 8px var(--text);
}
.config-env-values--blurred .cfg-input::placeholder,
.config-env-values--blurred textarea::placeholder {
text-shadow: none;
color: var(--muted);
opacity: 0.7;
}
.config-env-values--blurred .cfg-input:focus,
.config-env-values--blurred .cfg-number__input:focus,
.config-env-values--blurred textarea:focus {
color: transparent;
text-shadow: 0 0 8px var(--text);
}
.config-env-values--visible.config-env-values--blurred .cfg-input,
.config-env-values--visible.config-env-values--blurred .cfg-number__input,
.config-env-values--visible.config-env-values--blurred textarea {
color: var(--text);
text-shadow: none;
}
.config-env-values--visible.config-env-values--blurred .cfg-input:focus,
.config-env-values--visible.config-env-values--blurred .cfg-number__input:focus,
.config-env-values--visible.config-env-values--blurred textarea:focus {
color: var(--text);
text-shadow: none;
}
.config-env-peek-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--duration-fast) ease;
flex-shrink: 0;
margin-left: auto;
}
.config-env-peek-btn:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.config-env-peek-btn--active {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.config-env-peek-btn svg {
flex-shrink: 0;
}
/* Raw JSON redaction blur */
.config-raw-redacted {
color: transparent !important;
text-shadow: 0 0 8px var(--text);
transition:
color var(--duration-normal, 250ms) ease,
text-shadow var(--duration-normal, 250ms) ease;
}

View File

@@ -1,554 +0,0 @@
/* ════════════════════════════════════════════════════════
Glass Component System
Glassmorphism primitives used across dashboard views.
════════════════════════════════════════════════════════ */
/* ─── Animations ─── */
@keyframes glass-enter {
from {
opacity: 0;
transform: scale(0.97) translateY(6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes modal-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-dialog-in {
from {
opacity: 0;
transform: scale(0.95) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes glass-dropdown-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ambient-drift {
0% {
background-position: 0% 0%;
}
50% {
background-position: 100% 100%;
}
100% {
background-position: 0% 0%;
}
}
@keyframes active-breathe {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
@keyframes card-rise {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.glass-animate-in {
animation: glass-enter var(--clay-duration-normal) var(--clay-easing) both;
}
/* ─── Glass Buttons ─── */
.glass-btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 10px 18px;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--kn-claw), var(--kn-claw-deep));
color: #fff;
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
position: relative;
overflow: hidden;
transition:
transform 0.15s ease,
box-shadow 0.2s ease,
filter 0.15s ease;
}
.glass-btn-primary:hover {
transform: translateY(-1px);
filter: brightness(1.1);
box-shadow: 0 4px 16px rgba(202, 58, 41, 0.3);
}
.glass-btn-primary:active {
transform: translateY(0);
}
.glass-btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 10px 18px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
color: var(--text);
font-weight: 500;
font-size: 0.88rem;
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.15s ease;
}
.glass-btn-secondary:hover {
border-color: var(--glass-border-hover);
background: var(--bg-hover);
}
.glass-btn-ocean {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 10px 18px;
border: 1px solid rgba(0, 212, 170, 0.2);
border-radius: var(--radius-sm);
background: rgba(0, 212, 170, 0.08);
color: var(--kn-bioluminescence);
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
transition:
border-color 0.2s ease,
background 0.15s ease;
}
.glass-btn-ocean:hover {
border-color: rgba(0, 212, 170, 0.35);
background: rgba(0, 212, 170, 0.14);
}
/* ─── Glass Input ─── */
.glass-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--text);
font-size: 0.92rem;
font-family: inherit;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.glass-input:focus {
outline: none;
border-color: var(--accent);
border-width: 2px;
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.glass-input::placeholder {
color: var(--muted);
}
/* ─── Glass Tabs ─── */
.glass-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--muted);
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
position: relative;
transition:
color 0.15s ease,
background 0.15s ease;
}
.glass-tab:hover {
color: var(--text);
background: var(--accent-subtle);
}
.glass-tab-active {
color: var(--text);
background: var(--accent-subtle);
font-weight: 600;
}
.glass-tab-active::after {
content: "";
position: absolute;
bottom: 0;
left: 20%;
width: 60%;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
border-radius: 1px;
}
.glass-segmented-control {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
background: var(--glass-bg);
}
/* ─── Glass Dialog ─── */
.glass-dialog {
background: var(--glass-bg-elevated);
backdrop-filter: blur(40px) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(40px) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
/* ─── Glass Select Panel (Dropdown) ─── */
.glass-select-panel {
background: var(--glass-bg-elevated);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
animation: glass-dropdown-in 0.15s ease-out both;
}
/* ─── Glass Overlay (Modal Backdrop) ─── */
.glass-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 100;
animation: modal-overlay-in 0.25s ease-out both;
}
/* ─── Glass Depth Layers ─── */
.glass-layer-1 {
background: var(--glass-bg);
backdrop-filter: blur(8px) saturate(120%);
-webkit-backdrop-filter: blur(8px) saturate(120%);
}
.glass-layer-2 {
background: var(--glass-bg-elevated);
backdrop-filter: blur(16px) saturate(140%);
-webkit-backdrop-filter: blur(16px) saturate(140%);
}
.glass-layer-3 {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
}
/* ─── Glass Card Variants ─── */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.glass-card:hover {
border-color: var(--glass-border-hover);
box-shadow: var(--shadow-md);
}
.glass-card-active {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
var(--shadow-md);
}
.glass-card-active-ocean {
border-color: var(--kn-bioluminescence);
box-shadow:
0 0 0 1px var(--kn-bioluminescence),
var(--shadow-md);
}
/* ─── Glass Noise Texture ─── */
.glass-noise::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.05;
mix-blend-mode: overlay;
pointer-events: none;
border-radius: inherit;
}
/* ─── Glass Border Gradient ─── */
.glass-border-gradient {
position: relative;
}
.glass-border-gradient::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(135deg, var(--glass-border-hover), transparent 60%);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.glass-border-gradient:hover::before {
opacity: 1;
}
/* ─── Ambient Background ─── */
.ambient-bg {
position: relative;
}
.ambient-bg::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 80% 50% at 20% 80%, var(--kn-claw-dim) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 20%, var(--kn-ocean-dim) 0%, transparent 50%);
}
.ambient-bg::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 50% 30% at 60% 60%, var(--kn-claw-dim) 0%, transparent 50%),
radial-gradient(ellipse 40% 50% at 30% 30%, rgba(0, 212, 170, 0.03) 0%, transparent 50%);
animation: ambient-drift 120s ease-in-out infinite alternate;
background-size: 200% 200%;
}
/* ─── Typography Utilities ─── */
.text-display {
font-weight: 700;
letter-spacing: -0.04em;
line-height: 1.1;
}
/* ─── Glass Dashboard Card ─── */
.glass-dashboard-card {
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
padding: 1.25rem;
overflow: hidden;
position: relative;
box-shadow: var(--shadow-sm), var(--glass-highlight);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
min-width: 0;
}
.glass-dashboard-card::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0;
transition: opacity 0.2s ease;
}
.glass-dashboard-card:hover {
border-color: var(--glass-border-hover);
box-shadow: var(--shadow-md);
}
.glass-dashboard-card:hover::after {
opacity: 0.6;
}
/* ─── Card Header Convention ─── */
.card-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.875rem;
min-height: 28px;
}
.card-header__prefix {
color: var(--accent);
font-family: var(--mono);
font-size: 0.82rem;
font-weight: 600;
line-height: 1;
}
.card-header__title {
font-size: 0.9rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.01em;
margin: 0;
}
.card-header__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-header__link {
font-size: 0.75rem;
color: var(--accent);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
white-space: nowrap;
}
.card-header__link:hover {
text-decoration: underline;
}
/* ─── Count Badge ─── */
.count-badge {
font-size: 0.72rem;
font-family: var(--mono);
font-variant-numeric: tabular-nums;
background: var(--clay-bg-card);
color: var(--muted);
padding: 1px 7px;
border-radius: 9999px;
line-height: 1.4;
white-space: nowrap;
}
.count-badge--accent {
color: var(--accent);
}
.count-badge--emerald {
color: var(--success);
}
.count-badge--amber {
color: var(--warn);
}
.count-badge--red {
color: var(--danger);
}
/* ─── Glass Divider ─── */
.glass-divider {
height: 1px;
background: var(--clay-border-subtle);
margin: 1.25rem 0;
border: none;
}
/* ─── Glass Event Row ─── */
.glass-event-row {
padding: 6px 8px;
border-radius: var(--clay-radius-sm);
cursor: pointer;
transition: background var(--clay-duration-fast) ease;
}
.glass-event-row:hover {
background: var(--clay-bg-interactive);
}

View File

@@ -5,8 +5,8 @@
.shell {
--shell-pad: 16px;
--shell-gap: 16px;
--shell-nav-width: 240px;
--shell-topbar-height: 62px;
--shell-nav-width: 220px;
--shell-topbar-height: 56px;
--shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out);
height: 100vh;
@@ -14,7 +14,7 @@
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) 1fr;
grid-template-areas:
"nav topbar"
"topbar topbar"
"nav content";
gap: 0;
animation: dashboard-enter 0.4s var(--ease-out);
@@ -41,7 +41,7 @@
}
.shell--nav-collapsed {
grid-template-columns: 60px minmax(0, 1fr);
grid-template-columns: 0px minmax(0, 1fr);
}
.shell--chat-focus {
@@ -80,262 +80,139 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
gap: 16px;
padding: 0 20px;
height: var(--shell-topbar-height);
background: var(--topbar-bg);
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
border-bottom: var(--topbar-border);
border-bottom: 1px solid var(--border);
background: var(--bg);
}
/* --- Left: Dashboard Header --- */
.dashboard-header {
.topbar-left {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
gap: 12px;
}
.dashboard-header__breadcrumb {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
min-width: 0;
.topbar .nav-collapse-toggle {
width: 36px;
height: 36px;
margin-bottom: 0;
}
.dashboard-header__breadcrumb-link {
color: var(--muted);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
}
.dashboard-header__breadcrumb-link:hover {
color: var(--text);
}
.dashboard-header__breadcrumb-sep {
color: var(--muted);
opacity: 0.5;
}
.dashboard-header__breadcrumb-current {
color: var(--text);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard-header__actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
/* --- Center: Search / Command Palette Trigger --- */
.topbar-search {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
min-width: 200px;
max-width: 340px;
flex: 1;
height: 34px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--secondary) 60%, transparent);
color: var(--muted);
font-size: 13px;
font-family: var(--font-body);
cursor: pointer;
transition:
border-color 180ms ease,
background 180ms ease,
box-shadow 180ms ease;
-webkit-appearance: none;
appearance: none;
}
.topbar-search:hover {
border-color: var(--border-strong);
background: color-mix(in srgb, var(--secondary) 85%, transparent);
}
.topbar-search:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.topbar-search__label {
flex: 1;
text-align: left;
pointer-events: none;
}
.topbar-search__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 6px;
min-width: 22px;
.topbar .nav-collapse-toggle__icon {
width: 20px;
height: 20px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg) 70%, transparent);
color: var(--muted);
font-size: 11px;
font-family: var(--font-body);
font-weight: 500;
line-height: 1;
pointer-events: none;
flex-shrink: 0;
}
/* --- Right: Status area --- */
.topbar .nav-collapse-toggle__icon svg {
width: 20px;
height: 20px;
}
.topbar-status {
/* Brand */
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.topbar-divider {
width: 1px;
height: 20px;
background: var(--border);
flex-shrink: 0;
.brand-logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Connection indicator */
.brand-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.topbar-connection {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-full);
font-size: 12px;
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-strong);
}
.brand-sub {
font-size: 10px;
font-weight: 500;
color: var(--danger);
background: var(--danger-subtle);
transition:
color 250ms ease,
background 250ms ease;
}
.topbar-connection--ok {
color: var(--ok);
background: var(--ok-subtle);
}
.topbar-connection__dot {
width: 6px;
height: 6px;
border-radius: var(--radius-full);
background: currentColor;
box-shadow: 0 0 6px currentColor;
flex-shrink: 0;
}
.topbar-connection:not(.topbar-connection--ok) .topbar-connection__dot {
animation: pulse-subtle 2s ease-in-out infinite;
}
.topbar-connection__label {
color: var(--muted);
letter-spacing: 0.05em;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1;
}
/* Redact / stream-mode toggle */
.topbar-redact {
display: inline-flex;
/* Topbar status */
.topbar-status {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius);
background: none;
color: var(--muted);
cursor: pointer;
transition:
color 180ms ease,
background 180ms ease,
border-color 180ms ease;
flex-shrink: 0;
gap: 8px;
}
.topbar-redact svg {
width: 14px;
height: 14px;
.topbar-status .pill {
padding: 6px 10px;
gap: 6px;
font-size: 12px;
font-weight: 500;
height: 32px;
box-sizing: border-box;
}
.topbar-redact:hover {
color: var(--text);
background: color-mix(in srgb, var(--secondary) 80%, transparent);
border-color: var(--border);
.topbar-status .pill .mono {
display: flex;
align-items: center;
line-height: 1;
margin-top: 0px;
}
.topbar-redact--active {
color: var(--warn);
.topbar-status .statusDot {
width: 6px;
height: 6px;
}
.topbar-redact--active:hover {
color: var(--warn);
background: var(--warn-subtle);
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
}
/* Topbar theme toggle sizing */
.topbar-status .theme-toggle {
height: 30px;
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
}
.topbar-status .theme-btn svg {
width: 13px;
height: 13px;
.topbar-status .theme-icon {
width: 12px;
height: 12px;
}
/* ===========================================
Navigation Sidebar
=========================================== */
.sidebar {
.nav {
grid-area: nav;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
background: var(--sidebar-bg);
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
padding: 16px 12px;
background: var(--bg);
scrollbar-width: none; /* Firefox */
transition:
width var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0;
border-right: 1px solid var(--glass-border);
}
.sidebar::-webkit-scrollbar {
display: none;
.nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.shell--chat-focus .sidebar {
.shell--chat-focus .nav {
width: 0;
padding: 0;
border-width: 0;
@@ -344,141 +221,51 @@
opacity: 0;
}
.sidebar--collapsed {
align-items: center;
}
.sidebar--collapsed .sidebar-header {
justify-content: center;
padding: 10px 8px;
min-height: 54px;
}
.sidebar--collapsed .nav-group__items {
padding: 4px 0;
align-items: center;
}
.sidebar--collapsed .nav-item {
margin: 0;
padding: 10px;
justify-content: center;
width: 44px;
height: 44px;
}
.sidebar--collapsed .nav-item__icon {
width: 22px;
height: 22px;
opacity: 0.85;
}
.sidebar--collapsed .nav-item__icon svg {
width: 22px;
height: 22px;
stroke-width: 1.75px;
}
.sidebar--collapsed .nav-item--active {
border-left: 0;
}
.sidebar--collapsed .sidebar-footer {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.sidebar--collapsed .sidebar-footer .nav-item {
margin: 0;
padding: 10px;
width: 44px;
height: 44px;
}
/* Sidebar header (brand + collapse) */
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 8px;
gap: 0;
flex-shrink: 0;
min-height: 54px;
}
.sidebar-brand {
flex: 2;
display: flex;
align-items: center;
gap: 10px;
.nav--collapsed {
width: 0;
min-width: 0;
max-height: 28px;
padding-left: 10px;
padding-right: 10px;
@media (max-width: 1100px) {
padding-left: 0;
padding-right: 0;
}
padding: 0;
overflow: hidden;
border: none;
opacity: 0;
pointer-events: none;
}
.sidebar-brand__logo {
width: 28px;
height: 28px;
flex-shrink: 0;
object-fit: contain;
}
.sidebar-brand__title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-strong);
white-space: nowrap;
}
.sidebar-collapse-btn {
flex: 1;
/* Nav collapse toggle */
.nav-collapse-toggle {
width: 32px;
height: 32px;
@media (max-width: 1100px) {
height: 28px;
}
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: var(--border) 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
color: var(--muted);
flex-shrink: 0;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease,
color var(--duration-fast) ease;
border-color var(--duration-fast) ease;
margin-bottom: 16px;
}
.sidebar--collapsed .sidebar-collapse-btn {
flex: none;
width: 100%;
}
.sidebar-collapse-btn:hover {
background: var(--bg);
.nav-collapse-toggle:hover {
background: var(--bg-hover);
border-color: var(--border);
color: var(--text);
}
.sidebar-collapse-btn svg {
width: 24px;
height: 24px;
.nav-collapse-toggle__icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--muted);
transition: color var(--duration-fast) ease;
}
.nav-collapse-toggle__icon svg {
width: 18px;
height: 18px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@@ -486,22 +273,13 @@
stroke-linejoin: round;
}
/* Sidebar nav section */
.sidebar-nav {
flex: 1;
padding: 4px 8px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: var(--text);
}
/* Nav groups */
.nav-group {
margin-bottom: 16px;
margin-bottom: 20px;
display: grid;
gap: 2px;
}
@@ -519,16 +297,16 @@
display: none;
}
/* Nav group label */
.nav-group__label {
/* Nav label */
.nav-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
font-size: 11px;
font-weight: 500;
color: var(--muted);
margin-bottom: 4px;
background: transparent;
@@ -536,40 +314,37 @@
cursor: pointer;
text-align: left;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.04em;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.nav-group__label:hover {
.nav-label:hover {
color: var(--text);
background: var(--bg-hover);
}
.nav-group__label-text {
.nav-label--static {
cursor: default;
}
.nav-label--static:hover {
color: var(--muted);
background: transparent;
}
.nav-label__text {
flex: 1;
}
.nav-group__chevron {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
.nav-label__chevron {
font-size: 10px;
opacity: 0.5;
transition: transform var(--duration-fast) ease;
}
.nav-group__chevron svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
.nav-group--collapsed .nav-label__chevron {
transform: rotate(-90deg);
}
/* Nav items */
@@ -579,7 +354,7 @@
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 9px 12px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
@@ -589,13 +364,12 @@
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
color var(--duration-fast) ease;
}
.nav-item__icon {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
@@ -605,8 +379,8 @@
}
.nav-item__icon svg {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
@@ -615,32 +389,14 @@
}
.nav-item__text {
font-size: 14px;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.nav-item__external-icon {
display: flex;
align-items: center;
margin-left: auto;
opacity: 0.4;
}
.nav-item__external-icon svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-item:hover {
color: var(--text);
background: color-mix(in srgb, var(--secondary) 90%, transparent);
border-color: color-mix(in srgb, var(--border) 75%, transparent);
background: var(--bg-hover);
text-decoration: none;
}
@@ -648,55 +404,23 @@
opacity: 1;
}
.nav-item--active {
.nav-item.active {
color: var(--text-strong);
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--secondary));
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
background: var(--accent-subtle);
}
.nav-item--active .nav-item__icon {
.nav-item.active .nav-item__icon {
opacity: 1;
color: var(--accent);
}
/* Sidebar footer — aligned with chat compose bar */
.sidebar-footer {
padding: 14px 8px 6px;
border-top: 1px solid var(--border);
flex-shrink: 0;
margin-top: auto;
}
.sidebar-version {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
}
.sidebar-version__text {
font-size: 12px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.02em;
}
.sidebar-version__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
opacity: 0.4;
}
/* ===========================================
Content Area
=========================================== */
.content {
grid-area: content;
padding: 14px 18px 36px;
padding: 12px 16px 32px;
display: block;
min-height: 0;
overflow-y: auto;
@@ -707,6 +431,10 @@
margin-top: 24px;
}
:root[data-theme="light"] .content {
background: var(--bg-content);
}
.content--chat {
display: flex;
flex-direction: column;
@@ -725,7 +453,7 @@
align-items: flex-end;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
padding: 4px 8px;
overflow: hidden;
transform-origin: top center;
transition:
@@ -745,7 +473,7 @@
}
.page-title {
font-size: 28px;
font-size: 26px;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.15;
@@ -754,7 +482,7 @@
.page-sub {
color: var(--muted);
font-size: 15px;
font-size: 14px;
font-weight: 400;
margin-top: 6px;
letter-spacing: -0.01em;
@@ -849,31 +577,16 @@
"content";
}
.sidebar {
.nav {
position: static;
max-height: none;
display: flex;
flex-direction: row;
gap: 6px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
}
.sidebar-header {
display: none;
}
.sidebar-footer {
display: none;
}
.sidebar-nav {
display: flex;
flex-direction: row;
gap: 6px;
padding: 10px 14px;
overflow-x: auto;
background: var(--bg);
}
.nav-group {
@@ -893,12 +606,8 @@
gap: 10px;
}
.topbar-search__kbd {
display: none;
}
.topbar-status {
flex-wrap: nowrap;
flex-wrap: wrap;
}
.table-head,

View File

@@ -4,22 +4,7 @@
/* Tablet: Horizontal nav */
@media (max-width: 1100px) {
.sidebar {
flex-direction: row;
flex-wrap: nowrap;
border-right: none;
border-bottom: 1px solid var(--border);
}
.sidebar-header {
display: none;
}
.sidebar-footer {
display: none;
}
.sidebar-nav {
.nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
@@ -30,7 +15,7 @@
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
.nav::-webkit-scrollbar {
display: none;
}
@@ -42,7 +27,7 @@
display: contents;
}
.nav-group__label {
.nav-label {
display: none;
}
@@ -71,56 +56,53 @@
padding: 10px 12px;
gap: 8px;
flex-direction: row;
flex-wrap: nowrap;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.sidebar-brand__title {
.brand {
flex: 1;
min-width: 0;
}
.brand-title {
font-size: 14px;
}
.dashboard-header__breadcrumb-link,
.dashboard-header__breadcrumb-sep {
display: none;
}
.topbar-search {
min-width: 0;
max-width: none;
flex: 1;
}
.topbar-search__label {
display: none;
}
.topbar-search__kbd {
display: none;
}
.topbar-connection__label {
display: none;
}
.topbar-divider {
.brand-sub {
display: none;
}
.topbar-status {
gap: 6px;
width: auto;
flex-wrap: nowrap;
}
.topbar-status .pill {
padding: 4px 8px;
font-size: 11px;
gap: 4px;
}
.topbar-status .pill .mono {
display: none;
}
.topbar-status .pill span:nth-child(2) {
display: none;
}
/* Nav */
.sidebar-nav {
.nav {
padding: 8px 10px;
gap: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
.nav::-webkit-scrollbar {
display: none;
}
@@ -128,7 +110,7 @@
display: contents;
}
.nav-group__label {
.nav-label {
display: none;
}
@@ -306,13 +288,11 @@
font-size: 11px;
}
/* Theme toggle */
.theme-toggle {
height: 28px;
}
.theme-btn svg {
width: 12px;
height: 12px;
--theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
}
.theme-icon {
@@ -331,11 +311,11 @@
padding: 8px 10px;
}
.sidebar-brand__title {
.brand-title {
font-size: 13px;
}
.sidebar-nav {
.nav {
padding: 6px 8px;
}
@@ -376,12 +356,15 @@
font-size: 11px;
}
.topbar-connection {
.topbar-status .pill {
padding: 3px 6px;
font-size: 10px;
}
.theme-toggle {
height: 26px;
--theme-item: 22px;
--theme-gap: 2px;
--theme-pad: 2px;
}
.theme-icon {

View File

@@ -50,7 +50,7 @@ function createHost() {
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "dark",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,

View File

@@ -24,7 +24,6 @@ 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 type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
@@ -34,7 +33,7 @@ import type { UiSettings } from "./storage.ts";
import type {
AgentsListResult,
PresenceEntry,
HealthSummary,
HealthSnapshot,
StatusSummary,
UpdateAvailable,
} from "./types.ts";
@@ -56,10 +55,7 @@ type GatewayHost = {
agentsLoading: boolean;
agentsList: AgentsListResult | null;
agentsError: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
debugHealth: HealthSummary | null;
debugHealth: HealthSnapshot | null;
assistantName: string;
assistantAvatar: string | null;
assistantAgentId: string | null;
@@ -160,7 +156,6 @@ 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 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]);
@@ -206,7 +201,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
{ ts: Date.now(), event: evt.event, payload: evt.payload },
...host.eventLogBuffer,
].slice(0, 250);
if (host.tab === "debug" || host.tab === "overview") {
if (host.tab === "debug") {
host.eventLog = host.eventLogBuffer;
}
@@ -298,7 +293,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as
| {
presence?: PresenceEntry[];
health?: HealthSummary;
health?: HealthSnapshot;
sessionDefaults?: SessionDefaultsSnapshot;
updateAvailable?: UpdateAvailable;
}
@@ -308,7 +303,6 @@ 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

@@ -10,6 +10,8 @@ import {
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
import {
applySettingsFromUrl,
attachThemeListener,
detachThemeListener,
inferBasePath,
syncTabWithLocation,
syncThemeWithSettings,
@@ -36,28 +38,14 @@ type LifecycleHost = {
topbarObserver: ResizeObserver | null;
};
function handleCmdK(host: LifecycleHost, e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
(host as unknown as { paletteOpen: boolean }).paletteOpen = !(
host as unknown as { paletteOpen: boolean }
).paletteOpen;
}
}
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
void loadControlUiBootstrapConfig(host);
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler);
(host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler = (e) =>
handleCmdK(host, e);
window.addEventListener(
"keydown",
(host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler,
);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {
@@ -74,13 +62,10 @@ export function handleFirstUpdated(host: LifecycleHost) {
export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler);
const cmdK = (host as unknown as { cmdKHandler?: (e: KeyboardEvent) => void }).cmdKHandler;
if (cmdK) {
window.removeEventListener("keydown", cmdK);
}
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
host.topbarObserver?.disconnect();
host.topbarObserver = null;
}

View File

@@ -1,4 +1,4 @@
import { html, nothing } from "lit";
import { html } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts";
@@ -49,12 +49,10 @@ 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 ${isActive ? "nav-item--active" : ""}"
class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => {
if (
event.defaultPrevented ||
@@ -79,7 +77,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
title=${titleForTab(tab)}
>
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
${!collapsed ? html`<span class="nav-item__text">${titleForTab(tab)}</span>` : nothing}
<span class="nav-item__text">${titleForTab(tab)}</span>
</a>
`;
}
@@ -396,18 +394,10 @@ function resolveSessionOptions(
return options;
}
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "dark", label: "Dark", iconKey: "monitor" },
{ id: "light", label: "Light", iconKey: "book" },
{ id: "openknot", label: "Knot", iconKey: "zap" },
{ id: "fieldmanual", label: "Field", iconKey: "terminal" },
{ id: "openai", label: "Ember", iconKey: "loader" },
{ id: "clawdash", label: "Chrome", iconKey: "settings" },
];
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
export function renderThemeToggle(state: AppViewState) {
const app = state as unknown as OpenClawApp;
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 };
@@ -418,34 +408,74 @@ export function renderThemeToggle(state: AppViewState) {
state.setTheme(next, context);
};
const handleCollapse = () => app.handleThemeToggleCollapse();
return html`
<div
class="theme-toggle"
@mouseleave=${handleCollapse}
@focusout=${(e: FocusEvent) => {
const toggle = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
if (!toggle.contains(document.activeElement)) {
handleCollapse();
}
});
}}
>
${state.themeOrder.map((id) => {
const opt = THEME_OPTIONS.find((o) => o.id === id)!;
return html`
<button
class="theme-btn ${state.theme === id ? "active" : ""}"
@click=${applyTheme(id)}
aria-pressed=${state.theme === id}
title=${opt.label}
>
${icons[opt.iconKey]}
</button>
`;
})}
<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>
`;
}
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>
`;
}
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>
`;
}
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>
`;
}

View File

@@ -1,8 +1,5 @@
import { html, nothing } from "lit";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
} from "../../../src/routing/session-key.js";
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { refreshChatAvatar } from "./app-chat.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
@@ -55,21 +52,17 @@ import {
updateSkillEdit,
updateSkillEnabled,
} from "./controllers/skills.ts";
import "./components/dashboard-header.ts";
import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { renderAgents } from "./views/agents.ts";
import { renderBottomTabs } from "./views/bottom-tabs.ts";
import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts";
import { renderCommandPalette } from "./views/command-palette.ts";
import { renderConfig } from "./views/config.ts";
import { renderCron } from "./views/cron.ts";
import { renderDebug } from "./views/debug.ts";
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
import { renderInstances } from "./views/instances.ts";
import { renderLoginGate } from "./views/login-gate.ts";
import { renderLogs } from "./views/logs.ts";
import { renderNodes } from "./views/nodes.ts";
import { renderOverview } from "./views/overview.ts";
@@ -96,15 +89,6 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
}
export function renderApp(state: AppViewState) {
// Gate: require successful gateway connection before showing the dashboard.
// The gateway URL confirmation overlay is always rendered so URL-param flows still work.
if (!state.connected) {
return html`
${renderLoginGate(state)}
${renderGatewayUrlConfirmation(state)}
`;
}
const presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
@@ -124,165 +108,83 @@ export function renderApp(state: AppViewState) {
null;
return html`
${renderCommandPalette({
open: state.paletteOpen,
query: (state as unknown as { paletteQuery?: string }).paletteQuery ?? "",
activeIndex: (state as unknown as { paletteActiveIndex?: number }).paletteActiveIndex ?? 0,
onToggle: () => {
state.paletteOpen = !state.paletteOpen;
},
onQueryChange: (q) => {
(state as unknown as { paletteQuery: string }).paletteQuery = q;
},
onActiveIndexChange: (i) => {
(state as unknown as { paletteActiveIndex: number }).paletteActiveIndex = i;
},
onNavigate: (tab) => {
state.setTab(tab as import("./navigation.ts").Tab);
},
onSlashCommand: (_cmd) => {
state.setTab("chat" as import("./navigation.ts").Tab);
},
})}
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<header class="topbar">
<dashboard-header .tab=${state.tab}></dashboard-header>
<button
class="topbar-search"
@click=${() => {
state.paletteOpen = !state.paletteOpen;
}}
title="Search or jump to… (⌘K)"
aria-label="Open command palette"
>
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">⌘K</kbd>
</button>
<div class="topbar-status">
<div class="topbar-left">
<button
class="topbar-redact ${state.streamMode ? "topbar-redact--active" : ""}"
@click=${() => {
state.streamMode = !state.streamMode;
try {
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
} catch {
/* */
}
}}
title="${state.streamMode ? "Sensitive data hidden — click to reveal" : "Sensitive data visible — click to hide"}"
aria-label="Toggle redaction"
aria-pressed=${state.streamMode}
class="nav-collapse-toggle"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
${state.streamMode ? icons.eye : icons.eyeOff}
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>
<span class="topbar-divider"></span>
<div class="topbar-connection ${state.connected ? "topbar-connection--ok" : ""}">
<span class="topbar-connection__dot"></span>
<span class="topbar-connection__label">${state.connected ? t("common.ok") : t("common.offline")}</span>
<div class="brand">
<div class="brand-logo">
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
</div>
<div class="brand-text">
<div class="brand-title">OPENCLAW</div>
<div class="brand-sub">Gateway Dashboard</div>
</div>
</div>
</div>
<div class="topbar-status">
<div class="pill">
<span class="statusDot ${state.connected ? "ok" : ""}"></span>
<span>${t("common.health")}</span>
<span class="mono">${state.connected ? t("common.ok") : t("common.offline")}</span>
</div>
<span class="topbar-divider"></span>
${renderThemeToggle(state)}
</div>
</header>
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-header">
${
state.settings.navCollapsed
? nothing
: html`
<div class="sidebar-brand">
<img class="sidebar-brand__logo" src="${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"}" alt="OpenClaw" />
<span class="sidebar-brand__title">OpenClaw</span>
</div>
`
}
<button
class="sidebar-collapse-btn"
@click=${() =>
state.applySettings({
...state.settings,
navCollapsed: !state.settings.navCollapsed,
})}
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
>
${state.settings.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
</button>
</div>
<nav class="sidebar-nav">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
const showItems = hasActiveTab || !isGroupCollapsed;
return html`
<div class="nav-group ${!showItems ? "nav-group--collapsed" : ""}">
${
!state.settings.navCollapsed
? html`
<button
class="nav-group__label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${showItems}
>
<span class="nav-group__label-text">${t(`nav.${group.label}`)}</span>
<span class="nav-group__chevron">${showItems ? icons.chevronDown : icons.chevronRight}</span>
</button>
`
: nothing
}
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
${TAB_GROUPS.map((group) => {
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
return html`
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<button
class="nav-label"
@click=${() => {
const next = { ...state.settings.navGroupsCollapsed };
next[group.label] = !isGroupCollapsed;
state.applySettings({
...state.settings,
navGroupsCollapsed: next,
});
}}
aria-expanded=${!isGroupCollapsed}
>
<span class="nav-label__text">${t(`nav.${group.label}`)}</span>
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
</button>
<div class="nav-group__items">
${group.tabs.map((tab) => renderTab(state, tab))}
</div>
`;
})}
</nav>
<div class="sidebar-footer">
<a
class="nav-item nav-item--external"
href="https://docs.openclaw.ai"
target="_blank"
rel="noreferrer"
title="${t("common.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
${
!state.settings.navCollapsed
? html`
</div>
`;
})}
<div class="nav-group nav-group--links">
<div class="nav-label nav-label--static">
<span class="nav-label__text">${t("common.resources")}</span>
</div>
<div class="nav-group__items">
<a
class="nav-item nav-item--external"
href="https://docs.openclaw.ai"
target="_blank"
rel="noreferrer"
title="${t("common.docs")} (opens in new tab)"
>
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span>
`
: nothing
}
</a>
${(() => {
const snapshot = state.hello?.snapshot as { server?: { version?: string } } | undefined;
const version = snapshot?.server?.version ?? "";
return version
? html`
<div class="sidebar-version" title=${`v${version}`}>
${
!state.settings.navCollapsed
? html`<span class="sidebar-version__text">v${version}</span>`
: html`
<span class="sidebar-version__dot"></span>
`
}
</div>
`
: nothing;
})()}
</a>
</div>
</div>
</aside>
<main class="content ${isChat ? "content--chat" : ""}">
@@ -323,15 +225,6 @@ export function renderApp(state: AppViewState) {
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
usageResult: state.usageResult,
sessionsResult: state.sessionsResult,
skillsReport: state.skillsReport,
cronJobs: state.cronJobs,
cronStatus: state.cronStatus,
attentionItems: state.attentionItems,
eventLog: state.eventLog,
overviewLogLines: state.overviewLogLines,
streamMode: state.streamMode,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
@@ -347,16 +240,6 @@ export function renderApp(state: AppViewState) {
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
onRefreshLogs: () => state.loadOverview(),
onToggleStreamMode: () => {
state.streamMode = !state.streamMode;
try {
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
} catch {
/* */
}
},
})
: nothing
}
@@ -407,7 +290,6 @@ export function renderApp(state: AppViewState) {
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
streamMode: state.streamMode,
onRefresh: () => loadPresence(state),
})
: nothing
@@ -476,47 +358,33 @@ export function renderApp(state: AppViewState) {
agentsList: state.agentsList,
selectedAgentId: resolvedAgentId,
activePanel: state.agentsPanel,
config: {
form: configValue,
loading: state.configLoading,
saving: state.configSaving,
dirty: state.configFormDirty,
},
channels: {
snapshot: state.channelsSnapshot,
loading: state.channelsLoading,
error: state.channelsError,
lastSuccess: state.channelsLastSuccess,
},
cron: {
status: state.cronStatus,
jobs: state.cronJobs,
loading: state.cronLoading,
error: state.cronError,
},
agentFiles: {
list: state.agentFilesList,
loading: state.agentFilesLoading,
error: state.agentFilesError,
active: state.agentFileActive,
contents: state.agentFileContents,
drafts: state.agentFileDrafts,
saving: state.agentFileSaving,
},
configForm: configValue,
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
channelsLoading: state.channelsLoading,
channelsError: state.channelsError,
channelsSnapshot: state.channelsSnapshot,
channelsLastSuccess: state.channelsLastSuccess,
cronLoading: state.cronLoading,
cronStatus: state.cronStatus,
cronJobs: state.cronJobs,
cronError: state.cronError,
agentFilesLoading: state.agentFilesLoading,
agentFilesError: state.agentFilesError,
agentFilesList: state.agentFilesList,
agentFileActive: state.agentFileActive,
agentFileContents: state.agentFileContents,
agentFileDrafts: state.agentFileDrafts,
agentFileSaving: state.agentFileSaving,
agentIdentityLoading: state.agentIdentityLoading,
agentIdentityError: state.agentIdentityError,
agentIdentityById: state.agentIdentityById,
agentSkills: {
report: state.agentSkillsReport,
loading: state.agentSkillsLoading,
error: state.agentSkillsError,
agentId: state.agentSkillsAgentId,
filter: state.skillsFilter,
},
sidebarFilter: state.agentsSidebarFilter,
onSidebarFilterChange: (value) => {
state.agentsSidebarFilter = value;
},
agentSkillsLoading: state.agentSkillsLoading,
agentSkillsReport: state.agentSkillsReport,
agentSkillsError: state.agentSkillsError,
agentSkillsAgentId: state.agentSkillsAgentId,
skillsFilter: state.skillsFilter,
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
@@ -655,9 +523,6 @@ export function renderApp(state: AppViewState) {
onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onCronRunNow: (_jobId) => {
// Stub: backend support pending
},
onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => {
if (resolvedAgentId) {
@@ -827,12 +692,6 @@ export function renderApp(state: AppViewState) {
: { fallbacks: normalized };
updateConfigFormValue(state, basePath, next);
},
onSetDefault: (agentId) => {
if (!configValue) {
return;
}
updateConfigFormValue(state, ["agents", "defaultId"], agentId);
},
})
: nothing
}
@@ -1001,45 +860,6 @@ export function renderApp(state: AppViewState) {
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
onClearHistory: async () => {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("sessions.reset", { key: state.sessionKey });
state.chatMessages = [];
state.chatStream = null;
state.chatRunId = null;
await loadChatHistory(state);
} catch (err) {
state.lastError = String(err);
}
},
agentsList: state.agentsList,
currentAgentId: resolvedAgentId ?? "main",
onAgentChange: (agentId: string) => {
state.sessionKey = buildAgentMainSessionKey({ agentId });
state.chatMessages = [];
state.chatStream = null;
state.chatRunId = null;
state.applySettings({
...state.settings,
sessionKey: state.sessionKey,
lastActiveSessionKey: state.sessionKey,
});
void loadChatHistory(state);
void state.loadAssistantIdentity();
},
onNavigateToAgent: () => {
state.agentsSelectedId = resolvedAgentId;
state.setTab("agents" as import("./navigation.ts").Tab);
},
onSessionSelect: (key: string) => {
state.setSessionKey(key);
state.chatMessages = [];
void loadChatHistory(state);
void state.loadAssistantIdentity();
},
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing
@@ -1077,7 +897,6 @@ export function renderApp(state: AppViewState) {
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
streamMode: state.streamMode,
onRawChange: (next) => {
state.configRaw = next;
},
@@ -1143,10 +962,6 @@ export function renderApp(state: AppViewState) {
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
${renderBottomTabs({
activeTab: state.tab,
onTabChange: (tab) => state.setTab(tab),
})}
</div>
`;
}

View File

@@ -13,14 +13,14 @@ const createHost = (tab: Tab): SettingsHost => ({
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "dark",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
},
theme: "dark",
theme: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
@@ -31,6 +31,8 @@ const createHost = (tab: Tab): SettingsHost => ({
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
});

View File

@@ -21,7 +21,6 @@ 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,
@@ -33,7 +32,7 @@ import {
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, AttentionItem } from "./types.ts";
import type { AgentsListResult } from "./types.ts";
type SettingsHost = {
settings: UiSettings;
@@ -52,6 +51,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;
};
@@ -258,7 +259,7 @@ export function inferBasePath() {
}
export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "dark";
host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme));
}
@@ -269,7 +270,44 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
}
const root = document.documentElement;
root.dataset.theme = resolved;
root.style.colorScheme = "dark";
root.style.colorScheme = resolved;
}
export function attachThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => {
if (host.theme !== "system") {
return;
}
applyResolvedTheme(host, event.matches ? "dark" : "light");
};
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;
}
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@@ -365,121 +403,13 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
}
export async function loadOverview(host: SettingsHost) {
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),
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),
]);
buildAttentionItems(app);
}
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?: { scopes?: string[] } } | null)?.auth;
if (auth?.scopes && !auth.scopes.includes("operator.read")) {
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 && Object.keys(s.missing).length > 0);
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) {

View File

@@ -8,22 +8,20 @@ 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 { ResolvedTheme, ThemeMode } from "./theme.ts";
import type { ThemeMode } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
AgentIdentityResult,
AttentionItem,
ChannelsStatusSnapshot,
ConfigSnapshot,
ConfigUiHints,
CronJob,
CronRunLogEntry,
CronStatus,
HealthSummary,
HealthSnapshot,
LogEntry,
LogLevel,
ModelCatalogEntry,
NostrProfile,
PresenceEntry,
SessionsUsageResult,
@@ -45,8 +43,7 @@ export type AppViewState = {
basePath: string;
connected: boolean;
theme: ThemeMode;
themeResolved: ResolvedTheme;
themeOrder: ThemeMode[];
themeResolved: "light" | "dark";
hello: GatewayHelloOk | null;
lastError: string | null;
eventLog: EventLogEntry[];
@@ -146,7 +143,6 @@ export type AppViewState = {
agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null;
agentsSidebarFilter: string;
sessionsLoading: boolean;
sessionsResult: SessionsListResult | null;
sessionsError: string | null;
@@ -204,13 +200,10 @@ 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: HealthSummary | null;
debugModels: ModelCatalogEntry[];
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
@@ -230,12 +223,6 @@ export type AppViewState = {
logsMaxBytes: number;
logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null;
// Overview dashboard state
attentionItems: AttentionItem[];
paletteOpen: boolean;
streamMode: boolean;
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>;
connect: () => void;

View File

@@ -60,7 +60,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 { VALID_THEMES, type ResolvedTheme, type ThemeMode } from "./theme.ts";
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
import type {
AgentsListResult,
AgentsFilesListResult,
@@ -70,10 +70,9 @@ import type {
CronJob,
CronRunLogEntry,
CronStatus,
HealthSummary,
HealthSnapshot,
LogEntry,
LogLevel,
ModelCatalogEntry,
PresenceEntry,
ChannelsStatusSnapshot,
SessionsListResult,
@@ -119,9 +118,8 @@ export class OpenClawApp extends LitElement {
@state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode();
@state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "dark";
@state() theme: ThemeMode = this.settings.theme ?? "system";
@state() themeResolved: ResolvedTheme = "dark";
@state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = [];
@@ -231,7 +229,6 @@ export class OpenClawApp extends LitElement {
@state() agentSkillsError: string | null = null;
@state() agentSkillsReport: SkillStatusReport | null = null;
@state() agentSkillsAgentId: string | null = null;
@state() agentsSidebarFilter = "";
@state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null;
@@ -307,23 +304,6 @@ 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;
paletteQuery = "";
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() overviewLogLines: string[] = [];
@state() overviewLogCursor = 0;
@state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@@ -332,14 +312,10 @@ 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: HealthSummary | null = null;
@state() debugModels: ModelCatalogEntry[] = [];
@state() debugHealth: HealthSnapshot | null = null;
@state() debugModels: unknown[] = [];
@state() debugHeartbeat: unknown = null;
@state() debugCallMethod = "";
@state() debugCallParams = "{}";
@@ -378,6 +354,8 @@ 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;
createRenderRoot() {
@@ -455,19 +433,6 @@ export class OpenClawApp extends LitElement {
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this.themeOrder = this.buildThemeOrder(next);
}
buildThemeOrder(active: ThemeMode): ThemeMode[] {
const all = [...VALID_THEMES];
const rest = all.filter((id) => id !== active);
return [active, ...rest];
}
handleThemeToggleCollapse() {
setTimeout(() => {
this.themeOrder = this.buildThemeOrder(this.theme);
}, 80);
}
async loadOverview() {

View File

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

View File

@@ -1,10 +1,9 @@
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 { detectTextDirection } from "../text-direction.ts";
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import type { MessageGroup } from "../types/chat-types.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
extractTextCached,
@@ -112,7 +111,6 @@ export function renderMessageGroup(
showReasoning: boolean;
assistantName?: string;
assistantAvatar?: string | null;
onDelete?: () => void;
},
) {
const normalizedRole = normalizeRoleForGrouping(group.role);
@@ -150,16 +148,6 @@ export function renderMessageGroup(
<div class="chat-group-footer">
<span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span>
${
opts.onDelete
? html`<button
class="chat-group-delete"
@click=${opts.onDelete}
title="Delete"
aria-label="Delete message"
>${icons.x}</button>`
: nothing
}
</div>
</div>
</div>
@@ -228,66 +216,6 @@ function renderMessageImages(images: ImageBlock[]) {
`;
}
/** Render tool cards inside a collapsed `<details>` element. */
function renderCollapsedToolCards(
toolCards: ToolCard[],
onOpenSidebar?: (content: string) => void,
) {
const calls = toolCards.filter((c) => c.kind === "call");
const results = toolCards.filter((c) => c.kind === "result");
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const summaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
return html`
<details class="chat-tools-collapse">
<summary class="chat-tools-summary">
<span class="chat-tools-summary__icon">${icons.zap}</span>
<span class="chat-tools-summary__count">${totalTools} tool${totalTools === 1 ? "" : "s"}</span>
<span class="chat-tools-summary__names">${summaryLabel}</span>
</summary>
<div class="chat-tools-collapse__body">
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
</details>
`;
}
/**
* Detect whether a trimmed string is a JSON object or array.
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
*/
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
const t = text.trim();
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
const parsed = JSON.parse(t);
return { parsed, pretty: JSON.stringify(parsed, null, 2) };
} catch {
return null;
}
}
return null;
}
/** Build a short summary label for collapsed JSON (type + key count or array length). */
function jsonSummaryLabel(parsed: unknown): string {
if (Array.isArray(parsed)) {
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`;
}
if (parsed && typeof parsed === "object") {
const keys = Object.keys(parsed as Record<string, unknown>);
if (keys.length <= 4) {
return `{ ${keys.join(", ")} }`;
}
return `Object (${keys.length} keys)`;
}
return "JSON";
}
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
@@ -315,9 +243,6 @@ function renderGroupedMessage(
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
// Detect pure-JSON messages and render as collapsible block
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
@@ -328,7 +253,7 @@ function renderGroupedMessage(
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return renderCollapsedToolCards(toolCards, onOpenSidebar);
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
}
if (!markdown && !hasToolCards && !hasImages) {
@@ -347,19 +272,11 @@ function renderGroupedMessage(
: 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
markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
}

View File

@@ -1,49 +0,0 @@
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

@@ -1,61 +0,0 @@
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

@@ -1,84 +0,0 @@
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;
};
export const SLASH_COMMANDS: SlashCommandDef[] = [
{ name: "help", description: "Show available commands", icon: "book", category: "session" },
{ name: "status", description: "Show current status", icon: "barChart", category: "session" },
{ name: "reset", description: "Reset session", icon: "refresh", category: "session" },
{ name: "compact", description: "Compact session context", icon: "loader", category: "session" },
{ name: "stop", description: "Stop current run", icon: "stop", category: "session" },
{
name: "model",
description: "Show/set model",
args: "<name>",
icon: "brain",
category: "model",
},
{
name: "think",
description: "Set thinking level",
args: "<off|low|medium|high>",
icon: "brain",
category: "model",
},
{
name: "verbose",
description: "Toggle verbose mode",
args: "<on|off|full>",
icon: "terminal",
category: "model",
},
{ name: "export", description: "Export session to HTML", icon: "download", category: "tools" },
{
name: "skill",
description: "Run a skill",
args: "<name>",
icon: "zap",
category: "tools",
},
{ name: "agents", description: "List agents", icon: "monitor", category: "agents" },
{
name: "kill",
description: "Abort sub-agents",
args: "<id|all>",
icon: "x",
category: "agents",
},
{
name: "steer",
description: "Steer a sub-agent",
args: "<id> <msg>",
icon: "send",
category: "agents",
},
{ name: "usage", description: "Show token usage", icon: "barChart", category: "tools" },
];
const CATEGORY_ORDER: SlashCommandCategory[] = ["session", "model", "agents", "tools"];
export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
session: "Session",
model: "Model",
agents: "Agents",
tools: "Tools",
};
export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
const commands = filter
? SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(filter.toLowerCase()))
: SLASH_COMMANDS;
return commands.toSorted((a, b) => {
const ai = CATEGORY_ORDER.indexOf(a.category ?? "session");
const bi = CATEGORY_ORDER.indexOf(b.category ?? "session");
return ai - bi;
});
}

View File

@@ -1,34 +0,0 @@
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 }))}
>
ClawDash
</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

@@ -197,7 +197,7 @@ describe("config form renderer", () => {
expect(container.textContent).toContain("Plugin Enabled");
});
it("passes mixed unions through for JSON fallback rendering", () => {
it("flags unsupported unions", () => {
const schema = {
type: "object",
properties: {
@@ -207,7 +207,7 @@ describe("config form renderer", () => {
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("mixed");
expect(analysis.unsupportedPaths).toContain("mixed");
});
it("supports nullable types", () => {

View File

@@ -1,24 +1,18 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { HealthSummary, ModelCatalogEntry, StatusSummary } from "../types.ts";
import { loadHealthState } from "./health.ts";
import { loadModels } from "./models.ts";
import type { HealthSnapshot, StatusSummary } from "../types.ts";
export type DebugState = {
client: GatewayBrowserClient | null;
connected: boolean;
debugLoading: boolean;
debugStatus: StatusSummary | null;
debugHealth: HealthSummary | null;
debugModels: ModelCatalogEntry[];
debugHealth: HealthSnapshot | null;
debugModels: unknown[];
debugHeartbeat: unknown;
debugCallMethod: string;
debugCallParams: string;
debugCallResult: string | null;
debugCallError: string | null;
/** Shared health state fields (written by {@link loadHealthState}). */
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
};
export async function loadDebug(state: DebugState) {
@@ -30,16 +24,16 @@ export async function loadDebug(state: DebugState) {
}
state.debugLoading = true;
try {
const [status, , models, heartbeat] = await Promise.all([
const [status, health, models, heartbeat] = await Promise.all([
state.client.request("status", {}),
loadHealthState(state),
loadModels(state.client),
state.client.request("health", {}),
state.client.request("models.list", {}),
state.client.request("last-heartbeat", {}),
]);
state.debugStatus = status as StatusSummary;
// Sync debugHealth from the shared healthResult for backward compat.
state.debugHealth = state.healthResult;
state.debugModels = models;
state.debugHealth = health as HealthSnapshot;
const modelPayload = models as { models?: unknown[] } | undefined;
state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : [];
state.debugHeartbeat = heartbeat;
} catch (err) {
state.debugCallError = String(err);

View File

@@ -1,62 +0,0 @@
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

@@ -1,18 +0,0 @@
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,41 +58,3 @@ export function parseList(input: string): string[] {
export function stripThinkingTags(value: string): string {
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
}
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

@@ -61,13 +61,6 @@ export type GatewayBrowserClientOptions = {
// 4008 = application-defined code (browser rejects 1008 "Policy Violation")
const CONNECT_FAILED_CLOSE_CODE = 4008;
const DEFAULT_OPERATOR_CONNECT_SCOPES = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
];
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
@@ -136,11 +129,6 @@ export class GatewayBrowserClient {
if (this.connectSent) {
return;
}
const nonce = this.connectNonce?.trim() ?? "";
if (!nonce) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce");
return;
}
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
@@ -152,9 +140,10 @@ export class GatewayBrowserClient {
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
@@ -164,6 +153,7 @@ export class GatewayBrowserClient {
role,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
@@ -179,12 +169,13 @@ export class GatewayBrowserClient {
publicKey: string;
signature: string;
signedAt: number;
nonce: string;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
@@ -237,11 +228,7 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello);
})
.catch(() => {
// Clear stale device token on any connect failure so the next attempt
// falls back to the shared gateway token (if present) or retries without
// a cached device token. Without this, a rotated/revoked device token
// causes an infinite mismatch loop when no shared token is configured.
if (deviceIdentity) {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
@@ -262,12 +249,10 @@ export class GatewayBrowserClient {
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (!nonce || nonce.trim().length === 0) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce");
return;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
}
this.connectNonce = nonce.trim();
void this.sendConnect();
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
@@ -321,10 +306,7 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer);
}
this.connectTimer = window.setTimeout(() => {
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) {
return;
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge timeout");
}, 2_000);
void this.sendConnect();
}, 750);
}
}

View File

@@ -228,147 +228,6 @@ 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>
`,
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>
`,
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>
`,
} as const;
export type IconName = keyof typeof icons;

View File

@@ -14,7 +14,6 @@ const allowedTags = [
"br",
"code",
"del",
"details",
"em",
"h1",
"h2",
@@ -27,7 +26,6 @@ const allowedTags = [
"p",
"pre",
"strong",
"summary",
"table",
"tbody",
"td",
@@ -134,35 +132,6 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
const htmlEscapeRenderer = new marked.Renderer();
htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text);
htmlEscapeRenderer.code = ({
text,
lang,
escaped,
}: {
text: string;
lang?: string;
escaped: boolean;
}) => {
const langClass = lang ? ` class="language-${lang}"` : "";
const safeText = escaped ? text : escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
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>${codeBlock}</details>`;
}
return codeBlock;
};
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")

View File

@@ -1,7 +1,7 @@
const KEY = "openclaw.control.settings.v1";
import { isSupportedLocale } from "../i18n/index.ts";
import { VALID_THEMES, type ThemeMode } from "./theme.ts";
import type { ThemeMode } from "./theme.ts";
export type UiSettings = {
gatewayUrl: string;
@@ -28,7 +28,7 @@ export function loadSettings(): UiSettings {
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "dark",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
@@ -57,9 +57,10 @@ export function loadSettings(): UiSettings {
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme: VALID_THEMES.has(parsed.theme as ThemeMode)
? (parsed.theme as ThemeMode)
: defaults.theme,
theme:
parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
? parsed.theme
: defaults.theme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:

View File

@@ -1,26 +1,16 @@
export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash";
export type ResolvedTheme = ThemeMode;
export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = "light" | "dark";
export const VALID_THEMES = new Set<ThemeMode>([
"dark",
"light",
"openknot",
"fieldmanual",
"openai",
"clawdash",
]);
const LEGACY_MAP: Record<string, ThemeMode> = {
defaultTheme: "dark",
docsTheme: "light",
lightTheme: "openknot",
landingTheme: "openknot",
newTheme: "openknot",
};
export function resolveTheme(mode: string): ResolvedTheme {
if (VALID_THEMES.has(mode as ThemeMode)) {
return mode as ThemeMode;
export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return LEGACY_MAP[mode] ?? "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
if (mode === "system") {
return getSystemTheme();
}
return mode;
}

View File

@@ -1,39 +0,0 @@
/**
* 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

@@ -556,35 +556,6 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */
export type HealthSummary = {
ok: boolean;
ts: number;
durationMs: number;
heartbeatSeconds: number;
defaultAgentId: string;
agents: Array<{ id: string; name?: string }>;
sessions: {
path: string;
count: number;
recent: Array<{
key: string;
updatedAt: number | null;
age: number | null;
}>;
};
};
/** A model entry returned by the gateway model-catalog endpoint. */
export type ModelCatalogEntry = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
};
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
@@ -595,16 +566,3 @@ 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

@@ -1,233 +0,0 @@
import { html, nothing } from "lit";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import {
agentAvatarHue,
agentBadgeText,
buildModelOptions,
normalizeAgentLabel,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentEmoji,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
import type { AgentsPanel } from "./agents.ts";
export 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;
onSelectPanel: (panel: AgentsPanel) => void;
}) {
const {
agent,
configForm,
agentFilesList,
agentIdentity,
agentIdentityLoading,
agentIdentityError,
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 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 = resolveModelFallbacks(config.entry?.model);
const fallbackChips = modelFallbacks ?? [];
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);
const badge = agentBadgeText(agent.id, params.defaultId);
const hue = agentAvatarHue(agent.id);
const displayName = normalizeAgentLabel(agent);
const subtitle = agent.identity?.theme?.trim() || "";
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="agent-identity-card" style="margin-top: 16px;">
<div class="agent-avatar" style="--agent-hue: ${hue}">
${resolvedEmoji || displayName.slice(0, 1)}
</div>
<div class="agent-identity-details">
<div class="agent-identity-name">${identityName}</div>
<div class="agent-identity-meta">
${identityEmoji !== "-" ? html`<span>${identityEmoji}</span>` : nothing}
${subtitle ? html`<span>${subtitle}</span>` : nothing}
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
${identityStatus ? html`<span class="muted">${identityStatus}</span>` : nothing}
</div>
</div>
</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="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=${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" style="min-width: 260px; flex: 1;">
<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="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

@@ -230,7 +230,7 @@ export function renderAgentChannels(params: {
const status = summary.total
? `${summary.connected}/${summary.total} connected`
: "no accounts";
const configLabel = summary.configured
const config = summary.configured
? `${summary.configured} configured`
: "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
@@ -243,23 +243,8 @@ export function renderAgentChannels(params: {
</div>
<div class="list-meta">
<div>${status}</div>
<div>${configLabel}</div>
<div>${config}</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(
@@ -287,7 +272,6 @@ 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`
@@ -357,12 +341,6 @@ 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>
`,

View File

@@ -301,27 +301,17 @@ export function renderAgentSkills(params: {
}
</div>
</div>
<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>
<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>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config
</button>

View File

@@ -189,14 +189,6 @@ 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 "-";

View File

@@ -8,7 +8,6 @@ import type {
CronStatus,
SkillStatusReport,
} from "../types.ts";
import { renderAgentOverview } from "./agents-panels-overview.ts";
import {
renderAgentFiles,
renderAgentChannels,
@@ -16,70 +15,54 @@ import {
} from "./agents-panels-status-files.ts";
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
import {
agentAvatarHue,
agentBadgeText,
buildAgentContext,
buildModelOptions,
normalizeAgentLabel,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentEmoji,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
export type ConfigState = {
form: Record<string, unknown> | null;
loading: boolean;
saving: boolean;
dirty: boolean;
};
export type ChannelsState = {
snapshot: ChannelsStatusSnapshot | null;
loading: boolean;
error: string | null;
lastSuccess: number | null;
};
export type CronState = {
status: CronStatus | null;
jobs: CronJob[];
loading: boolean;
error: string | null;
};
export type AgentFilesState = {
list: AgentsFilesListResult | null;
loading: boolean;
error: string | null;
active: string | null;
contents: Record<string, string>;
drafts: Record<string, string>;
saving: boolean;
};
export type AgentSkillsState = {
report: SkillStatusReport | null;
loading: boolean;
error: string | null;
agentId: string | null;
filter: string;
};
export type AgentsProps = {
loading: boolean;
error: string | null;
agentsList: AgentsListResult | null;
selectedAgentId: string | null;
activePanel: AgentsPanel;
config: ConfigState;
channels: ChannelsState;
cron: CronState;
agentFiles: AgentFilesState;
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;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkills: AgentSkillsState;
sidebarFilter: string;
onSidebarFilterChange: (value: string) => void;
agentSkillsLoading: boolean;
agentSkillsReport: SkillStatusReport | null;
agentSkillsError: string | null;
agentSkillsAgentId: string | null;
skillsFilter: string;
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void;
@@ -96,13 +79,20 @@ 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;
onSetDefault: (agentId: string) => void;
};
export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
skillsLabel: string;
isDefault: boolean;
};
export function renderAgents(props: AgentsProps) {
@@ -113,27 +103,6 @@ export function renderAgents(props: AgentsProps) {
? (agents.find((agent) => agent.id === selectedId) ?? null)
: null;
const sidebarFilter = props.sidebarFilter.trim().toLowerCase();
const filteredAgents = sidebarFilter
? agents.filter((agent) => {
const label = normalizeAgentLabel(agent).toLowerCase();
return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter);
})
: agents;
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">
@@ -146,21 +115,6 @@ export function renderAgents(props: AgentsProps) {
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${
agents.length > 1
? html`
<input
class="field"
type="text"
placeholder="Filter agents…"
.value=${props.sidebarFilter}
@input=${(e: Event) =>
props.onSidebarFilterChange((e.target as HTMLInputElement).value)}
style="margin-top: 8px;"
/>
`
: nothing
}
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
@@ -168,23 +122,20 @@ export function renderAgents(props: AgentsProps) {
}
<div class="agent-list" style="margin-top: 12px;">
${
filteredAgents.length === 0
agents.length === 0
? html`
<div class="muted">${sidebarFilter ? "No matching agents." : "No agents found."}</div>
<div class="muted">No agents found.</div>
`
: filteredAgents.map((agent) => {
: agents.map((agent) => {
const badge = agentBadgeText(agent.id, defaultId);
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
const hue = agentAvatarHue(agent.id);
return html`
<button
type="button"
class="agent-row ${selectedId === agent.id ? "active" : ""}"
@click=${() => props.onSelectAgent(agent.id)}
>
<div class="agent-avatar" style="--agent-hue: ${hue}">
${emoji || normalizeAgentLabel(agent).slice(0, 1)}
</div>
<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>
@@ -210,27 +161,25 @@ export function renderAgents(props: AgentsProps) {
selectedAgent,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
props.onSetDefault,
)}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
${
props.activePanel === "overview"
? renderAgentOverview({
agent: selectedAgent,
defaultId,
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
configForm: props.configForm,
agentFilesList: props.agentFilesList,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
configLoading: props.configLoading,
configSaving: props.configSaving,
configDirty: props.configDirty,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
@@ -238,13 +187,13 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
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,
agentFilesList: props.agentFilesList,
agentFilesLoading: props.agentFilesLoading,
agentFilesError: props.agentFilesError,
agentFileActive: props.agentFileActive,
agentFileContents: props.agentFileContents,
agentFileDrafts: props.agentFileDrafts,
agentFileSaving: props.agentFileSaving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
@@ -257,10 +206,10 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
configForm: props.configForm,
configLoading: props.configLoading,
configSaving: props.configSaving,
configDirty: props.configDirty,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
@@ -272,15 +221,15 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
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,
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,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
@@ -296,16 +245,16 @@ export function renderAgents(props: AgentsProps) {
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
props.configForm,
props.agentFilesList,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
configForm: props.configForm,
snapshot: props.channelsSnapshot,
loading: props.channelsLoading,
error: props.channelsError,
lastSuccess: props.channelsLastSuccess,
onRefresh: props.onChannelsRefresh,
})
: nothing
@@ -315,18 +264,17 @@ export function renderAgents(props: AgentsProps) {
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
props.configForm,
props.agentFilesList,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
jobs: props.cronJobs,
status: props.cronStatus,
loading: props.cronLoading,
error: props.cronError,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
})
: nothing
}
@@ -337,32 +285,19 @@ export function renderAgents(props: AgentsProps) {
`;
}
let actionsMenuOpen = false;
function renderAgentHeader(
agent: AgentsListResult["agents"][number],
defaultId: string | null,
agentIdentity: AgentIdentityResult | null,
onSetDefault: (agentId: string) => void,
) {
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);
const hue = agentAvatarHue(agent.id);
const isDefault = Boolean(defaultId && agent.id === defaultId);
const copyId = () => {
void navigator.clipboard.writeText(agent.id);
actionsMenuOpen = false;
};
return html`
<section class="card agent-header">
<div class="agent-header-main">
<div class="agent-avatar agent-avatar--lg" style="--agent-hue: ${hue}">
${emoji || displayName.slice(0, 1)}
</div>
<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>
@@ -370,47 +305,13 @@ function renderAgentHeader(
</div>
<div class="agent-header-meta">
<div class="mono">${agent.id}</div>
<div class="row" style="gap: 8px; align-items: center;">
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
<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=${copyId}>Copy agent ID</button>
<button
type="button"
?disabled=${isDefault}
@click=${() => {
onSetDefault(agent.id);
actionsMenuOpen = false;
}}
>
${isDefault ? "Already default" : "Set as default"}
</button>
</div>
`
: nothing
}
</div>
</div>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
</div>
</section>
`;
}
function renderAgentTabs(
active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void,
counts: Record<string, number | null>,
) {
function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) {
const tabs: Array<{ id: AgentsPanel; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "files", label: "Files" },
@@ -428,10 +329,161 @@ function renderAgentTabs(
type="button"
@click=${() => onSelect(tab.id)}
>
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
${tab.label}
</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 = resolveModelFallbacks(config.entry?.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

@@ -1,33 +0,0 @@
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>
`;
}

View File

@@ -247,7 +247,7 @@ export function renderNostrProfileForm(params: {
@click=${callbacks.onSave}
?disabled=${state.saving || !isDirty}
>
${state.saving ? "Saving..." : "Save"}
${state.saving ? "Saving..." : "Save & Publish"}
</button>
<button

View File

@@ -45,9 +45,6 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
onSend: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
agentsList: null,
currentAgentId: "main",
onAgentChange: () => undefined,
...overrides,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,244 +0,0 @@
import { html, nothing } from "lit";
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 handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
const items = filteredItems(props.query);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
props.onActiveIndexChange(Math.min(props.activeIndex + 1, items.length - 1));
break;
case "ArrowUp":
e.preventDefault();
props.onActiveIndexChange(Math.max(props.activeIndex - 1, 0));
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",
};
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()}>
<input
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);
}}
@keydown=${(e: KeyboardEvent) => handleKeydown(e, props)}
autofocus
/>
<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=${() => 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

@@ -118,47 +118,12 @@ function normalizeSchemaNode(
};
}
function mergeAllOf(schema: JsonSchema, path: Array<string | number>): ConfigSchemaAnalysis | null {
const branches = schema.allOf;
if (!branches || branches.length === 0) {
return null;
}
const merged: JsonSchema = { ...schema, allOf: undefined };
for (const branch of branches) {
if (!branch || typeof branch !== "object") {
return null;
}
if (branch.type) {
merged.type = merged.type ?? branch.type;
}
if (branch.properties) {
merged.properties = { ...merged.properties, ...branch.properties };
}
if (branch.items && !merged.items) {
merged.items = branch.items;
}
if (branch.enum) {
merged.enum = branch.enum;
}
if (branch.description && !merged.description) {
merged.description = branch.description;
}
if (branch.title && !merged.title) {
merged.title = branch.title;
}
if (branch.default !== undefined && merged.default === undefined) {
merged.default = branch.default;
}
}
return normalizeSchemaNode(merged, path);
}
function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
): ConfigSchemaAnalysis | null {
if (schema.allOf) {
return mergeAllOf(schema, path);
return null;
}
const union = schema.anyOf ?? schema.oneOf;
if (!union) {
@@ -216,7 +181,7 @@ function normalizeUnion(
};
}
if (remaining.length === 1 && literals.length === 0) {
if (remaining.length === 1) {
const res = normalizeSchemaNode(remaining[0], path);
if (res.schema) {
res.schema.nullable = nullable || res.schema.nullable;
@@ -224,41 +189,6 @@ function normalizeUnion(
return res;
}
// Literals + single typed remainder (e.g. boolean | enum["off","partial"]):
// merge literals into an enum on the combined schema so segmented/select renders all options.
if (remaining.length === 1 && literals.length > 0) {
const remType = schemaType(remaining[0]);
if (remType === "boolean") {
const all = [true, false, ...literals];
const unique: unknown[] = [];
for (const v of all) {
if (!unique.some((e) => Object.is(e, v))) {
unique.push(v);
}
}
return {
schema: {
...schema,
enum: unique,
nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
},
unsupportedPaths: [],
};
}
// Single remaining primitive — pass through as-is so the renderer picks the right widget
const primitiveTypes = new Set(["string", "number", "integer"]);
if (remType && primitiveTypes.has(remType)) {
const res = normalizeSchemaNode(remaining[0], path);
if (res.schema) {
res.schema.nullable = nullable || res.schema.nullable;
}
return res;
}
}
const primitiveTypes = new Set(["string", "number", "integer", "boolean"]);
if (
remaining.length > 0 &&
@@ -274,9 +204,5 @@ function normalizeUnion(
};
}
// Fallback: pass the schema through and let the renderer show a JSON textarea
return {
schema: { ...schema, nullable },
unsupportedPaths: [],
};
return null;
}

View File

@@ -27,44 +27,6 @@ function jsonValue(value: unknown): string {
}
}
function renderJsonFallback(params: {
label: string;
help: string | undefined;
value: unknown;
path: Array<string | number>;
disabled: boolean;
showLabel: boolean;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { label, help, value, path, disabled, showLabel, onPatch } = params;
const display = jsonValue(value);
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<textarea
class="cfg-textarea"
rows=${Math.min(Math.max((display.match(/\n/g)?.length ?? 0) + 1, 2), 10)}
placeholder="JSON value"
.value=${display}
?disabled=${disabled}
@change=${(e: Event) => {
const raw = (e.target as HTMLTextAreaElement).value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
// leave as-is if invalid JSON
}
}}
></textarea>
</div>
`;
}
// SVG Icons as template literals
const icons = {
chevronDown: html`
@@ -151,7 +113,10 @@ export function renderNode(params: {
const key = pathKey(path);
if (unsupported.has(key)) {
return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel });
return html`<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
</div>`;
}
// Handle anyOf/oneOf unions
@@ -317,8 +282,13 @@ export function renderNode(params: {
return renderTextInput({ ...params, inputType: "text" });
}
// Fallback — render a JSON textarea for types the form renderer doesn't know about
return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel });
// Fallback
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
</div>
`;
}
function renderTextInput(params: {

View File

@@ -25,7 +25,6 @@ describe("config view", () => {
searchQuery: "",
activeSection: null,
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
@@ -38,7 +37,7 @@ describe("config view", () => {
onSubsectionChange: vi.fn(),
});
it("allows save with mixed union schemas", () => {
it("allows save when form is unsafe", () => {
const container = document.createElement("div");
render(
renderConfig({

View File

@@ -1,5 +1,4 @@
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
@@ -23,7 +22,6 @@ export type ConfigProps = {
searchQuery: string;
activeSection: string | null;
activeSubsection: string | null;
streamMode: boolean;
onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
@@ -385,44 +383,6 @@ function truncateValue(value: unknown, maxLen = 40): string {
return str.slice(0, maxLen - 3) + "...";
}
const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i;
const SENSITIVE_KEY_WHITELIST_RE =
/maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i;
function countSensitiveValues(formValue: Record<string, unknown> | null): number {
if (!formValue) {
return 0;
}
let count = 0;
function walk(obj: unknown, key?: string) {
if (obj == null) {
return;
}
if (typeof obj === "object" && !Array.isArray(obj)) {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
walk(v, k);
}
} else if (Array.isArray(obj)) {
for (const item of obj) {
walk(item);
}
} else if (
key &&
typeof obj === "string" &&
SENSITIVE_KEY_RE.test(key) &&
!SENSITIVE_KEY_WHITELIST_RE.test(key)
) {
if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) {
count++;
}
}
}
walk(formValue);
return count;
}
let rawRevealed = false;
export function renderConfig(props: ConfigProps) {
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema);
@@ -689,32 +649,6 @@ export function renderConfig(props: ConfigProps) {
: nothing
}
</div>
${
props.activeSection === "env"
? html`
<button
class="config-env-peek-btn"
title="Toggle value visibility"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const content = btn
.closest(".config-main")
?.querySelector(".config-content");
if (content) {
content.classList.toggle("config-env-values--visible");
}
btn.classList.toggle("config-env-peek-btn--active");
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Peek
</button>
`
: nothing
}
</div>
`
: nothing
@@ -748,7 +682,7 @@ export function renderConfig(props: ConfigProps) {
}
<!-- Form content -->
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}">
<div class="config-content">
${
props.formMode === "form"
? html`
@@ -782,43 +716,16 @@ export function renderConfig(props: ConfigProps) {
: nothing
}
`
: (() => {
const sensitiveCount = countSensitiveValues(props.formValue);
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
return html`
<label class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw JSON5
${
sensitiveCount > 0
? html`
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
<button
class="btn btn--icon ${blurred ? "" : "active"}"
style="width:28px;height:28px;padding:0;"
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
rawRevealed = !rawRevealed;
props.onRawChange(props.raw);
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`;
})()
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`
}
</div>

View File

@@ -333,7 +333,7 @@ export function renderCron(props: CronProps) {
<div class="muted" style="margin-top: 12px">No runs yet.</div>
`
: html`
<div class="list list-scroll" style="margin-top: 12px;">
<div class="list" style="margin-top: 12px;">
${orderedRuns.map((entry) => renderRun(entry, props.basePath))}
</div>
`

View File

@@ -1,13 +1,12 @@
import { html, nothing } from "lit";
import type { EventLogEntry } from "../app-events.ts";
import { formatEventPayload } from "../presenter.ts";
import type { HealthSummary, ModelCatalogEntry } from "../types.ts";
export type DebugProps = {
loading: boolean;
status: Record<string, unknown> | null;
health: HealthSummary | null;
models: ModelCatalogEntry[];
health: Record<string, unknown> | null;
models: unknown[];
heartbeat: unknown;
eventLog: EventLogEntry[];
callMethod: string;

View File

@@ -1,6 +1,5 @@
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import { formatPresenceAge } from "../presenter.ts";
import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts";
import type { PresenceEntry } from "../types.ts";
export type InstancesProps = {
@@ -8,15 +7,10 @@ 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;">
@@ -24,24 +18,9 @@ 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>
<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>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${
props.lastError
@@ -63,18 +42,16 @@ export function renderInstances(props: InstancesProps) {
? html`
<div class="muted">No instances reported yet.</div>
`
: props.entries.map((entry) => renderEntry(entry, masked))
: props.entries.map((entry) => renderEntry(entry))
}
</div>
</section>
`;
}
function renderEntry(entry: PresenceEntry, masked: boolean) {
function renderEntry(entry: PresenceEntry) {
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 =
@@ -86,12 +63,8 @@ function renderEntry(entry: PresenceEntry, masked: boolean) {
return html`
<div class="list-item">
<div class="list-main">
<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="list-title">${entry.host ?? "unknown host"}</div>
<div class="list-sub">${formatPresenceSummary(entry)}</div>
<div class="chip-row">
<span class="chip">${mode}</span>
${roles.map((role) => html`<span class="chip">${role}</span>`)}

View File

@@ -1,86 +0,0 @@
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 { 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.password")}</span>
<input
type="password"
.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();
}
}}
/>
</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 style="font-weight: 600; font-size: 12px; margin-bottom: 8px;">${t("overview.connection.title")}</div>
<ol class="muted" style="margin: 0; padding-left: 16px; font-size: 12px; line-height: 1.7;">
<li>${t("overview.connection.step1")}
<div class="mono" style="font-size: 11px; margin: 2px 0 4px;">openclaw gateway run</div>
</li>
<li>${t("overview.connection.step2")}
<div class="mono" style="font-size: 11px; margin: 2px 0 4px;">openclaw dashboard --no-open</div>
</li>
<li>${t("overview.connection.step3")}</li>
</ol>
<div class="muted" style="font-size: 11px; margin-top: 8px;">
<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

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

View File

@@ -1,129 +0,0 @@
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 { icons } from "../icons.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)}`;
}
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;
return html`
<section class="ov-cards">
<div class="card ov-stat-card clickable" data-kind="cost" @click=${() => props.onNavigate("usage")}>
<div class="ov-stat-card__inner">
<div class="ov-stat-card__icon">${icons.barChart}</div>
<div class="ov-stat-card__body">
<div class="stat-label">${t("overview.cards.cost")}</div>
<div class="stat-value ${props.redacted ? "redacted" : ""}">${redact(totalCost, props.redacted)}</div>
<div class="muted">${redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted)}</div>
</div>
</div>
</div>
<div class="card ov-stat-card clickable" data-kind="sessions" @click=${() => props.onNavigate("sessions")}>
<div class="ov-stat-card__inner">
<div class="ov-stat-card__icon">${icons.fileText}</div>
<div class="ov-stat-card__body">
<div class="stat-label">${t("overview.stats.sessions")}</div>
<div class="stat-value">${sessionCount ?? t("common.na")}</div>
<div class="muted">${t("overview.stats.sessionsHint")}</div>
</div>
</div>
</div>
<div class="card ov-stat-card clickable" data-kind="skills" @click=${() => props.onNavigate("skills")}>
<div class="ov-stat-card__inner">
<div class="ov-stat-card__icon">${icons.zap}</div>
<div class="ov-stat-card__body">
<div class="stat-label">${t("overview.cards.skills")}</div>
<div class="stat-value">${enabledSkills}/${totalSkills}</div>
<div class="muted">${blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`}</div>
</div>
</div>
</div>
<div class="card ov-stat-card clickable" data-kind="cron" @click=${() => props.onNavigate("cron")}>
<div class="ov-stat-card__inner">
<div class="ov-stat-card__icon">${icons.scrollText}</div>
<div class="ov-stat-card__body">
<div class="stat-label">${t("overview.stats.cron")}</div>
<div class="stat-value">
${cronEnabled == null ? t("common.na") : cronEnabled ? `${cronJobCount} jobs` : t("common.disabled")}
</div>
<div class="muted">
${
failedCronCount > 0
? html`<span class="danger">${failedCronCount} failed</span>`
: nothing
}
${cronNext ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) : ""}
</div>
</div>
</div>
</div>
</section>
${
props.sessionsResult && props.sessionsResult.sessions.length > 0
? html`
<section class="card ov-recent-sessions">
<div class="card-title">${t("overview.cards.recentSessions")}</div>
<div class="ov-session-list">
${props.sessionsResult.sessions.slice(0, 5).map(
(s) => html`
<div class="ov-session-row ${props.redacted ? "redacted" : ""}">
<span class="ov-session-key">${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)}</span>
<span class="muted">${s.model ?? ""}</span>
<span class="muted">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
</div>
`,
)}
</div>
</section>
`
: nothing
}
`;
}

View File

@@ -1,43 +0,0 @@
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,36 +0,0 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
export type OverviewLogTailProps = {
lines: string[];
redacted: boolean;
onRefreshLogs: () => void;
};
export function renderOverviewLogTail(props: OverviewLogTailProps) {
if (props.lines.length === 0) {
return nothing;
}
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" : ""}">${
props.redacted ? "[log hidden]" : props.lines.slice(-50).join("\n")
}</pre>
</details>
`;
}

View File

@@ -1,31 +0,0 @@
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,23 +1,10 @@
import { html, nothing } from "lit";
import { html } from "lit";
import { t, i18n, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts";
import { icons } from "../icons.ts";
import { formatNextRun } from "../presenter.ts";
import type { UiSettings } from "../storage.ts";
import type {
AttentionItem,
CronJob,
CronStatus,
SessionsListResult,
SessionsUsageResult,
SkillStatusReport,
} from "../types.ts";
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = {
connected: boolean;
@@ -30,24 +17,11 @@ 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;
onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void;
onConnect: () => void;
onRefresh: () => void;
onNavigate: (tab: string) => void;
onRefreshLogs: () => void;
onToggleStreamMode: () => void;
};
export function renderOverview(props: OverviewProps) {
@@ -60,7 +34,7 @@ export function renderOverview(props: OverviewProps) {
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs
? `${(snapshot.policy.tickIntervalMs / 1000).toFixed(snapshot.policy.tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
? `${snapshot.policy.tickIntervalMs}ms`
: t("common.na");
const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy";
@@ -190,7 +164,7 @@ export function renderOverview(props: OverviewProps) {
<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 ${props.streamMode ? "redacted" : ""}" style="margin-top: 16px;">
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>${t("overview.access.wsUrl")}</span>
<input
@@ -209,8 +183,6 @@ export function renderOverview(props: OverviewProps) {
<label class="field">
<span>${t("overview.access.token")}</span>
<input
type="password"
autocomplete="off"
.value=${props.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
@@ -267,36 +239,6 @@ export function renderOverview(props: OverviewProps) {
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span>
</div>
${
!props.connected
? html`
<div style="margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px;">
<div style="font-weight: 600; font-size: 13px; margin-bottom: 10px;">${t("overview.connection.title")}</div>
<ol class="muted" style="margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.8;">
<li>${t("overview.connection.step1")}
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw gateway run</div>
</li>
<li>${t("overview.connection.step2")}
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw dashboard --no-open</div>
</li>
<li>${t("overview.connection.step3")}</li>
<li>${t("overview.connection.step4")}
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw doctor --generate-gateway-token</div>
</li>
</ol>
<div class="muted" style="font-size: 12px; margin-top: 10px;">
${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">
@@ -341,43 +283,45 @@ export function renderOverview(props: OverviewProps) {
</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
}
${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-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="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>
<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

@@ -54,16 +54,16 @@ export const usageStylesPart1 = `
align-items: center;
gap: 6px;
padding: 4px 10px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
background: rgba(255, 77, 77, 0.1);
border-radius: 4px;
font-size: 12px;
color: var(--accent);
color: #ff4d4d;
}
.usage-refresh-indicator::before {
content: "";
width: 10px;
height: 10px;
border: 2px solid var(--accent);
border: 2px solid #ff4d4d;
border-top-color: transparent;
border-radius: 50%;
animation: usage-spin 0.6s linear infinite;
@@ -161,36 +161,36 @@ export const usageStylesPart1 = `
border-color: var(--border-strong);
}
.usage-primary-btn {
background: var(--accent);
background: #ff4d4d;
color: #fff;
border-color: var(--accent);
border-color: #ff4d4d;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12);
}
.btn.usage-primary-btn {
background: var(--accent) !important;
border-color: var(--accent) !important;
background: #ff4d4d !important;
border-color: #ff4d4d !important;
color: #fff !important;
}
.usage-primary-btn:hover {
background: var(--accent-strong);
border-color: var(--accent-strong);
background: #e64545;
border-color: #e64545;
}
.btn.usage-primary-btn:hover {
background: var(--accent-strong) !important;
border-color: var(--accent-strong) !important;
background: #e64545 !important;
border-color: #e64545 !important;
}
.usage-primary-btn:disabled {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--accent);
background: rgba(255, 77, 77, 0.18);
border-color: rgba(255, 77, 77, 0.3);
color: #ff4d4d;
box-shadow: none;
cursor: default;
opacity: 1;
}
.usage-primary-btn[disabled] {
background: color-mix(in srgb, var(--accent) 18%, transparent) !important;
border-color: color-mix(in srgb, var(--accent) 30%, transparent) !important;
color: var(--accent) !important;
background: rgba(255, 77, 77, 0.18) !important;
border-color: rgba(255, 77, 77, 0.3) !important;
color: #ff4d4d !important;
opacity: 1 !important;
}
.usage-secondary-btn {
@@ -533,8 +533,8 @@ export const usageStylesPart1 = `
border-radius: 8px;
padding: 10px;
color: var(--text);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
background: rgba(255, 77, 77, 0.08);
border: 1px solid rgba(255, 77, 77, 0.2);
display: flex;
flex-direction: column;
gap: 4px;
@@ -554,14 +554,14 @@ export const usageStylesPart1 = `
.usage-hour-cell {
height: 28px;
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
background: rgba(255, 77, 77, 0.1);
border: 1px solid rgba(255, 77, 77, 0.2);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.usage-hour-cell.selected {
border-color: color-mix(in srgb, var(--accent) 80%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
border-color: rgba(255, 77, 77, 0.8);
box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2);
}
.usage-hour-labels {
display: grid;
@@ -584,8 +584,8 @@ export const usageStylesPart1 = `
width: 14px;
height: 10px;
border-radius: 4px;
background: color-mix(in srgb, var(--accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
background: rgba(255, 77, 77, 0.15);
border: 1px solid rgba(255, 77, 77, 0.2);
}
.usage-calendar-labels {
display: grid;
@@ -603,8 +603,8 @@ export const usageStylesPart1 = `
.usage-calendar-cell {
height: 18px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border: 1px solid rgba(255, 77, 77, 0.2);
background: rgba(255, 77, 77, 0.08);
}
.usage-calendar-cell.empty {
background: transparent;

View File

@@ -100,7 +100,7 @@ export const usageStylesPart2 = `
color: var(--text);
}
.chart-toggle .toggle-btn.active {
background: var(--accent);
background: #ff4d4d;
color: white;
}
.chart-toggle.small .toggle-btn {
@@ -157,14 +157,14 @@ export const usageStylesPart2 = `
.daily-bar {
width: 100%;
max-width: var(--bar-max-width, 32px);
background: var(--accent);
background: #ff4d4d;
border-radius: 3px 3px 0 0;
min-height: 2px;
transition: all 0.15s;
overflow: hidden;
}
.daily-bar-wrapper:hover .daily-bar {
background: var(--accent-strong);
background: #cc3d3d;
}
.daily-bar-label {
position: absolute;
@@ -282,7 +282,7 @@ export const usageStylesPart2 = `
background: #06b6d4;
}
.legend-dot.system {
background: var(--accent);
background: #ff4d4d;
}
.legend-dot.skills {
background: #8b5cf6;
@@ -360,7 +360,7 @@ export const usageStylesPart2 = `
}
.session-bar-fill {
height: 100%;
background: color-mix(in srgb, var(--accent) 70%, transparent);
background: rgba(255, 77, 77, 0.7);
border-radius: 4px;
transition: width 0.3s ease;
}
@@ -431,27 +431,27 @@ export const usageStylesPart2 = `
fill: var(--muted);
}
.timeseries-svg .ts-area {
fill: var(--accent);
fill: #ff4d4d;
fill-opacity: 0.1;
}
.timeseries-svg .ts-line {
fill: none;
stroke: var(--accent);
stroke: #ff4d4d;
stroke-width: 2;
}
.timeseries-svg .ts-dot {
fill: var(--accent);
fill: #ff4d4d;
transition: r 0.15s, fill 0.15s;
}
.timeseries-svg .ts-dot:hover {
r: 5;
}
.timeseries-svg .ts-bar {
fill: var(--accent);
fill: #ff4d4d;
transition: fill 0.15s;
}
.timeseries-svg .ts-bar:hover {
fill: var(--accent-strong);
fill: #cc3d3d;
}
.timeseries-svg .ts-bar.output { fill: #ef4444; }
.timeseries-svg .ts-bar.input { fill: #f59e0b; }
@@ -582,7 +582,7 @@ export const usageStylesPart2 = `
transition: width 0.3s ease;
}
.context-segment.system {
background: var(--accent);
background: #ff4d4d;
}
.context-segment.skills {
background: #8b5cf6;

View File

@@ -121,7 +121,7 @@ export const usageStylesPart3 = `
.sessions-card .session-bar-row.selected {
border-color: var(--accent);
background: var(--accent-subtle);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 77, 77, 0.15);
}
.sessions-card .session-bar-label {
flex: 1 1 auto;
@@ -139,7 +139,7 @@ export const usageStylesPart3 = `
opacity: 0.5;
}
.sessions-card .session-bar-fill {
background: color-mix(in srgb, var(--accent) 55%, transparent);
background: rgba(255, 77, 77, 0.55);
}
.sessions-clear-btn {
margin-left: auto;

View File

@@ -34,7 +34,7 @@ export default defineConfig(() => {
},
server: {
host: true,
port: 5174,
port: 5173,
strictPort: true,
},
};