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/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.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> </head>
<body> <body>
<openclaw-app></openclaw-app> <openclaw-app></openclaw-app>

View File

@@ -12,7 +12,6 @@ export const en: TranslationMap = {
na: "n/a", na: "n/a",
docs: "Docs", docs: "Docs",
resources: "Resources", resources: "Resources",
search: "Search",
}, },
nav: { nav: {
chat: "Chat", 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.", 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).", 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: { chat: {
disconnected: "Disconnected from gateway.", disconnected: "Disconnected from gateway.",

View File

@@ -12,7 +12,6 @@ export const pt_BR: TranslationMap = {
na: "n/a", na: "n/a",
docs: "Docs", docs: "Docs",
resources: "Recursos", resources: "Recursos",
search: "Pesquisar",
}, },
nav: { nav: {
chat: "Chat", 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.", 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).", 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: { chat: {
disconnected: "Desconectado do gateway.", disconnected: "Desconectado do gateway.",

View File

@@ -12,7 +12,6 @@ export const zh_CN: TranslationMap = {
na: "不适用", na: "不适用",
docs: "文档", docs: "文档",
resources: "资源", resources: "资源",
search: "搜索",
}, },
nav: { nav: {
chat: "聊天", chat: "聊天",
@@ -104,47 +103,6 @@ export const zh_CN: TranslationMap = {
hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。", hint: "此页面为 HTTP因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
stayHttp: "如果您必须保持 HTTP请设置 {config} (仅限令牌)。", 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: { chat: {
disconnected: "已断开与网关的连接。", disconnected: "已断开与网关的连接。",

View File

@@ -12,7 +12,6 @@ export const zh_TW: TranslationMap = {
na: "不適用", na: "不適用",
docs: "文檔", docs: "文檔",
resources: "資源", resources: "資源",
search: "搜尋",
}, },
nav: { nav: {
chat: "聊天", chat: "聊天",
@@ -104,47 +103,6 @@ export const zh_TW: TranslationMap = {
hint: "此頁面為 HTTP因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。", hint: "此頁面為 HTTP因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
stayHttp: "如果您必須保持 HTTP請設置 {config} (僅限令牌)。", 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: { chat: {
disconnected: "已斷開與網關的連接。", disconnected: "已斷開與網關的連接。",

View File

@@ -2,5 +2,4 @@
@import "./styles/layout.css"; @import "./styles/layout.css";
@import "./styles/layout.mobile.css"; @import "./styles/layout.mobile.css";
@import "./styles/components.css"; @import "./styles/components.css";
@import "./styles/glass.css";
@import "./styles/config.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"); @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
* {
box-sizing: border-box;
}
/* ════════════════════════════════════════════════════════
Theme System — 6 Glassmorphism Themes
════════════════════════════════════════════════════════ */
/* ─── Design Tokens (shared across all themes) ─── */
:root { :root {
--icon-size-xs: 0.9rem; /* Background - Warmer dark with depth */
--icon-size-sm: 1.05rem; --bg: #12141a;
--icon-size-md: 1.25rem; --bg-accent: #14161d;
--icon-size-xl: 2.4rem; --bg-elevated: #1a1d25;
--bg-hover: #262a35;
--bg-muted: #262a35;
--font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; /* Card / Surface - More contrast between levels */
--font-serif: "Playfair Display", Georgia, "Times New Roman", serif; --card: #181b22;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --card-foreground: #f4f4f5;
--card-highlight: rgba(255, 255, 255, 0.05);
--popover: #181b22;
--popover-foreground: #f4f4f5;
--theme-switch-x: 50%; /* Panel */
--theme-switch-y: 50%; --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) { /* Text - Slightly warmer */
:root { --text: #e4e4e7;
--clay-duration-fast: 0ms; --text-strong: #fafafa;
--clay-duration-normal: 0ms; --chat-text: #e4e4e7;
--clay-duration-slow: 0ms; --muted: #71717a;
} --muted-strong: #52525b;
--muted-foreground: #71717a;
* { /* Border - Subtle but defined */
animation-duration: 0s !important; --border: #27272a;
transition-duration: 0s !important; --border-strong: #3f3f46;
} --border-hover: #52525b;
} --input: #27272a;
--ring: #ff5c5c;
/* ─── Theme: dark (Home) — Deep-sea Operations Console ─── */ /* Accent - Punchy signature red */
--accent: #ff5c5c;
:root, --accent-hover: #ff7070;
:root[data-theme="dark"] { --accent-muted: #ff5c5c;
color-scheme: dark; --accent-subtle: rgba(255, 92, 92, 0.15);
--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-foreground: #fafafa; --accent-foreground: #fafafa;
--accent-glow: var(--kn-claw-dim); --accent-glow: rgba(255, 92, 92, 0.25);
--accent-soft: var(--vscode-accent-alpha); --primary: #ff5c5c;
--primary: var(--vscode-accent);
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
/* Secondary */ /* Secondary - Teal accent for variety */
--secondary: var(--vscode-sidebar); --secondary: #1e2028;
--secondary-foreground: var(--vscode-text); --secondary-foreground: #f4f4f5;
--accent-2: var(--kn-bioluminescence); --accent-2: #14b8a6;
--accent-2-muted: var(--kn-silver); --accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: var(--kn-silver-dim); --accent-2-subtle: rgba(20, 184, 166, 0.15);
/* Semantic */ /* Semantic - More saturated */
--ok: var(--vscode-success); --ok: #22c55e;
--ok-muted: var(--vscode-success); --ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: var(--kn-silver-dim); --ok-subtle: rgba(34, 197, 94, 0.12);
--destructive: var(--vscode-danger); --destructive: #ef4444;
--destructive-foreground: #fafafa; --destructive-foreground: #fafafa;
--warn: var(--kn-claw-ember); --warn: #f59e0b;
--warn-muted: var(--kn-claw-ember); --warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: var(--kn-claw-dim); --warn-subtle: rgba(245, 158, 11, 0.12);
--danger: var(--vscode-danger); --danger: #ef4444;
--danger-muted: var(--vscode-danger); --danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: var(--kn-claw-dim); --danger-subtle: rgba(239, 68, 68, 0.12);
--info: #3b82f6; --info: #3b82f6;
--success: var(--vscode-success);
/* Focus */ /* Focus - With glow */
--focus: var(--kn-claw-dim); --focus: rgba(255, 92, 92, 0.25);
--focus-offset-color: var(--bg); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
--focus-ring-width: 2px; --focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
--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);
/* Grid */
--grid-line: rgba(255, 255, 255, 0.04); --grid-line: rgba(255, 255, 255, 0.04);
/* Shadows */ /* Theme transition */
--shadow-sm: var(--glass-shadow-sm); --theme-switch-x: 50%;
--shadow-md: var(--glass-shadow-md); --theme-switch-y: 50%;
--shadow-lg: var(--glass-shadow-lg);
--shadow-xl: var(--glass-shadow-lg); /* 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); --shadow-glow: 0 0 30px var(--accent-glow);
/* Radii — aliased from foundation */ /* Radii - Slightly larger for friendlier feel */
--radius: var(--radius-md); --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-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
@@ -502,68 +110,88 @@
--duration-normal: 200ms; --duration-normal: 200ms;
--duration-slow: 350ms; --duration-slow: 350ms;
/* Typography stacks */ color-scheme: dark;
--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);
} }
/* ─── 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) { --card: #ffffff;
:root { --card-foreground: #18181b;
--glass-shadow-sm: 0 0 0 2px var(--vscode-text); --card-highlight: rgba(0, 0, 0, 0.03);
--glass-shadow-md: 0 0 0 2px var(--vscode-text); --popover: #ffffff;
--glass-shadow-lg: 0 0 0 2px var(--vscode-text); --popover-foreground: #18181b;
--glass-border: rgba(255, 255, 255, 0.3);
} --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, html,
body { body {
@@ -572,8 +200,8 @@ body {
body { body {
margin: 0; margin: 0;
font: 400 15px/1.55 var(--font-body); font: 400 14px/1.55 var(--font-body);
letter-spacing: -0.01em; letter-spacing: -0.02em;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -661,170 +289,7 @@ select {
background: var(--border-strong); background: var(--border-strong);
} }
/* ════════════════════════════════════════════════════════ /* Animations - Polished with spring feel */
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
════════════════════════════════════════════════════════ */
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
@@ -896,15 +361,6 @@ select {
} }
} }
@keyframes chrome-shimmer {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
/* Stagger animation delays for grouped elements */ /* Stagger animation delays for grouped elements */
.stagger-1 { .stagger-1 {
animation-delay: 0ms; animation-delay: 0ms;

View File

@@ -3,4 +3,3 @@
@import "./chat/grouped.css"; @import "./chat/grouped.css";
@import "./chat/tool-cards.css"; @import "./chat/tool-cards.css";
@import "./chat/sidebar.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 */ /* Avatar Styles */
.chat-avatar { .chat-avatar {
width: 38px; width: 40px;
height: 38px; height: 40px;
border-radius: var(--radius-md); border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); background: var(--panel-strong);
background: color-mix(in srgb, var(--panel-strong) 95%, transparent);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 14px;
flex-shrink: 0; flex-shrink: 0;
align-self: flex-end; /* Align with last message in group */ align-self: flex-end; /* Align with last message in group */
margin-bottom: 4px; /* Optical alignment */ margin-bottom: 4px; /* Optical alignment */
@@ -128,15 +127,14 @@ img.chat-avatar {
.chat-bubble { .chat-bubble {
position: relative; position: relative;
display: inline-block; display: inline-block;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid transparent;
background: color-mix(in srgb, var(--card) 97%, transparent); background: var(--card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 10px 14px; padding: 10px 14px;
box-shadow: inset 0 1px 0 var(--card-highlight); box-shadow: none;
transition: transition:
background 150ms ease-out, background 150ms ease-out,
border-color 150ms ease-out, border-color 150ms ease-out;
box-shadow 150ms ease-out;
max-width: 100%; max-width: 100%;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -149,8 +147,8 @@ img.chat-avatar {
position: absolute; position: absolute;
top: 6px; top: 6px;
right: 8px; right: 8px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 94%, transparent); background: var(--bg);
color: var(--muted); color: var(--muted);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 4px 6px; padding: 4px 6px;
@@ -161,8 +159,7 @@ img.chat-avatar {
pointer-events: none; pointer-events: none;
transition: transition:
opacity 120ms ease-out, opacity 120ms ease-out,
background 120ms ease-out, background 120ms ease-out;
border-color 120ms ease-out;
} }
.chat-copy-btn__icon { .chat-copy-btn__icon {
@@ -209,7 +206,6 @@ img.chat-avatar {
.chat-copy-btn:hover { .chat-copy-btn:hover {
background: var(--bg-hover); background: var(--bg-hover);
border-color: var(--border-strong);
} }
.chat-copy-btn[data-copying="1"] { .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 { .chat-bubble:hover {
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); background: var(--bg-hover);
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
} }
/* User bubbles have different styling */ /* User bubbles have different styling */
.chat-group.user .chat-bubble { .chat-group.user .chat-bubble {
background: color-mix(in srgb, var(--accent-subtle) 85%, var(--secondary)); background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 30%, transparent); 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 { .chat-group.user .chat-bubble:hover {
background: var(--danger-subtle); background: rgba(255, 77, 77, 0.15);
} }
/* Streaming animation */ /* Streaming animation */
@@ -293,59 +298,3 @@ img.chat-avatar {
transform: translateY(0); 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 */ flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 14px 8px; padding: 12px 4px;
margin: 0 -4px; margin: 0 -4px;
min-height: 0; /* Allow shrinking for flex scroll behavior */ min-height: 0; /* Allow shrinking for flex scroll behavior */
border-radius: var(--radius-lg); border-radius: 12px;
background: linear-gradient( background: transparent;
180deg,
color-mix(in srgb, var(--panel) 72%, transparent),
transparent
);
} }
/* Focus mode exit button */ /* Focus mode exit button */
@@ -115,22 +111,20 @@
font-size: 13px; font-size: 13px;
font-family: var(--font-body); font-family: var(--font-body);
color: var(--text); color: var(--text);
background: color-mix(in srgb, var(--panel-strong) 92%, transparent); background: var(--panel-strong);
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); border: 1px solid var(--border);
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
z-index: 10; z-index: 10;
transition: transition:
background 150ms ease-out, background 150ms ease-out,
border-color 150ms ease-out, border-color 150ms ease-out;
box-shadow 150ms ease-out;
} }
.chat-new-messages:hover { .chat-new-messages:hover {
background: var(--panel); background: var(--panel);
border-color: color-mix(in srgb, var(--accent) 36%, transparent); border-color: var(--accent);
box-shadow: var(--shadow-sm);
} }
.chat-new-messages svg { .chat-new-messages svg {
@@ -153,9 +147,8 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
margin-top: auto; /* Push to bottom of flex container */ margin-top: auto; /* Push to bottom of flex container */
padding: 14px 6px 6px; padding: 12px 4px 4px;
background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%); background: linear-gradient(to bottom, transparent, var(--bg) 20%);
backdrop-filter: blur(4px);
z-index: 10; z-index: 10;
} }
@@ -225,6 +218,21 @@
stroke-width: 2px; 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) */ /* Message images (sent images displayed in chat) */
.chat-message-images { .chat-message-images {
display: flex; display: flex;
@@ -259,6 +267,10 @@
flex: 1; flex: 1;
} }
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}
.chat-compose__field { .chat-compose__field {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -278,16 +290,13 @@
min-height: 40px; min-height: 40px;
max-height: 150px; max-height: 150px;
padding: 9px 12px; padding: 9px 12px;
border-radius: var(--radius-md); border-radius: 8px;
overflow-y: auto; overflow-y: auto;
resize: none; resize: none;
white-space: pre-wrap; white-space: pre-wrap;
font-family: var(--font-body); font-family: var(--font-body);
font-size: 14px; font-size: 14px;
line-height: 1.45; 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 { .chat-compose__field textarea:disabled {
@@ -342,22 +351,25 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid var(--border);
background: color-mix(in srgb, var(--secondary) 85%, transparent); background: rgba(255, 255, 255, 0.06);
border-radius: var(--radius-md);
} }
/* Controls separator */ /* Controls separator */
.chat-controls__separator { .chat-controls__separator {
color: var(--border); color: rgba(255, 255, 255, 0.4);
font-size: 18px; font-size: 18px;
margin: 0 8px; margin: 0 8px;
font-weight: 300; font-weight: 300;
} }
:root[data-theme="light"] .chat-controls__separator {
color: rgba(16, 24, 40, 0.3);
}
.btn--icon:hover { .btn--icon:hover {
background: var(--bg-hover); background: rgba(255, 255, 255, 0.12);
border-color: var(--border-strong); border-color: rgba(255, 255, 255, 0.2);
} }
/* Light theme icon button overrides */ /* Light theme icon button overrides */
@@ -374,6 +386,27 @@
color: var(--text); 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 { .btn--icon svg {
display: block; display: block;
width: 18px; width: 18px;
@@ -399,9 +432,15 @@
gap: 4px; gap: 4px;
font-size: 12px; font-size: 12px;
padding: 4px 10px; padding: 4px 10px;
background: color-mix(in srgb, var(--secondary) 90%, transparent); background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius-sm); border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); 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) { @media (max-width: 640px) {

View File

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

View File

@@ -5,12 +5,17 @@
.chat-thinking { .chat-thinking {
margin-bottom: 10px; margin-bottom: 10px;
padding: 10px 12px; padding: 10px 12px;
border-radius: var(--radius-md); border-radius: 10px;
border: 1px dashed color-mix(in srgb, var(--border) 84%, transparent); border: 1px dashed rgba(255, 255, 255, 0.18);
background: color-mix(in srgb, var(--secondary) 75%, transparent); background: rgba(255, 255, 255, 0.04);
color: var(--muted); color: var(--muted);
font-size: 12px; 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 { .chat-text {
@@ -52,16 +57,14 @@
} }
.chat-text :where(:not(pre) > code) { .chat-text :where(:not(pre) > code) {
background: color-mix(in srgb, var(--secondary) 82%, transparent); background: rgba(0, 0, 0, 0.15);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); padding: 0.15em 0.4em;
padding: 0.15em 0.42em; border-radius: 4px;
border-radius: 5px;
} }
.chat-text :where(pre) { .chat-text :where(pre) {
background: color-mix(in srgb, var(--secondary) 82%, transparent); background: rgba(0, 0, 0, 0.15);
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); border-radius: 6px;
border-radius: var(--radius-md);
padding: 10px 12px; padding: 10px 12px;
overflow-x: auto; overflow-x: auto;
} }
@@ -71,50 +74,12 @@
padding: 0; 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) { .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; padding-left: 12px;
margin-left: 0; margin-left: 0;
color: var(--muted); color: var(--muted);
background: color-mix(in srgb, var(--secondary) 78%, transparent); background: rgba(255, 255, 255, 0.02);
padding: 8px 12px; padding: 8px 12px;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
} }
@@ -122,12 +87,34 @@
.chat-text :where(blockquote blockquote) { .chat-text :where(blockquote blockquote) {
margin-top: 8px; margin-top: 8px;
border-left-color: var(--border-hover); 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) { .chat-text :where(blockquote blockquote blockquote) {
border-left-color: var(--muted-strong); 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) { .chat-text :where(hr) {

View File

@@ -1,15 +1,14 @@
/* Tool Card Styles */ /* Tool Card Styles */
.chat-tool-card { .chat-tool-card {
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: 8px;
padding: 12px; padding: 12px;
margin-top: 8px; margin-top: 8px;
background: color-mix(in srgb, var(--card) 97%, transparent); background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight); box-shadow: inset 0 1px 0 var(--card-highlight);
transition: transition:
border-color 150ms ease-out, border-color 150ms ease-out,
background 150ms ease-out, background 150ms ease-out;
box-shadow 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */ /* Fixed max-height to ensure cards don't expand too much */
max-height: 120px; max-height: 120px;
overflow: hidden; overflow: hidden;
@@ -17,8 +16,7 @@
.chat-tool-card:hover { .chat-tool-card:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover)); background: var(--bg-hover);
box-shadow: var(--shadow-sm);
} }
/* First tool card in a group - no top margin */ /* First tool card in a group - no top margin */
@@ -130,13 +128,13 @@
color: var(--muted); color: var(--muted);
margin-top: 8px; margin-top: 8px;
padding: 8px 10px; padding: 8px 10px;
background: color-mix(in srgb, var(--secondary) 92%, transparent); background: var(--secondary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
white-space: pre-wrap; white-space: pre-wrap;
overflow: hidden; overflow: hidden;
max-height: 44px; max-height: 44px;
line-height: 1.4; 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 { .chat-tool-card--clickable:hover .chat-tool-card__preview {
@@ -150,18 +148,16 @@
color: var(--text); color: var(--text);
margin-top: 6px; margin-top: 6px;
padding: 6px 8px; padding: 6px 8px;
background: color-mix(in srgb, var(--secondary) 92%, transparent); background: var(--secondary);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
/* Reading Indicator */ /* Reading Indicator */
.chat-reading-indicator { .chat-reading-indicator {
background: color-mix(in srgb, var(--secondary) 70%, transparent); background: transparent;
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px; padding: 12px;
display: inline-flex; display: inline-flex;
} }
@@ -204,176 +200,3 @@
transform: scale(1); 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; overflow: hidden;
} }
:root[data-theme="light"] .config-sidebar {
background: var(--bg-hover);
}
.config-sidebar__header { .config-sidebar__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -37,7 +41,7 @@
.config-sidebar__title { .config-sidebar__title {
font-weight: 600; font-weight: 600;
font-size: 15px; font-size: 14px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@@ -71,7 +75,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--bg-elevated); background: var(--bg-elevated);
font-size: 14px; font-size: 13px;
outline: none; outline: none;
transition: transition:
border-color var(--duration-fast) ease, border-color var(--duration-fast) ease,
@@ -89,6 +93,14 @@
background: var(--bg-hover); 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 { .config-search__clear {
position: absolute; position: absolute;
right: 22px; right: 22px;
@@ -133,7 +145,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
@@ -147,6 +159,10 @@
color: var(--text); color: var(--text);
} }
:root[data-theme="light"] .config-nav__item:hover {
background: rgba(0, 0, 0, 0.04);
}
.config-nav__item.active { .config-nav__item.active {
background: var(--accent-subtle); background: var(--accent-subtle);
color: var(--accent); color: var(--accent);
@@ -190,6 +206,10 @@
border: 1px solid var(--border); border: 1px solid var(--border);
} }
:root[data-theme="light"] .config-mode-toggle {
background: white;
}
.config-mode-toggle__btn { .config-mode-toggle__btn {
flex: 1; flex: 1;
padding: 9px 14px; padding: 9px 14px;
@@ -240,6 +260,10 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
:root[data-theme="light"] .config-actions {
background: var(--bg-hover);
}
.config-actions__left, .config-actions__left,
.config-actions__right { .config-actions__right {
display: flex; display: flex;
@@ -251,7 +275,7 @@
padding: 6px 14px; padding: 6px 14px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--accent-subtle); 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); color: var(--accent);
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -265,7 +289,7 @@
/* Diff Panel */ /* Diff Panel */
.config-diff { .config-diff {
margin: 18px 22px 0; 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); border-radius: var(--radius-lg);
background: var(--accent-subtle); background: var(--accent-subtle);
overflow: hidden; overflow: hidden;
@@ -319,6 +343,10 @@
font-family: var(--mono); font-family: var(--mono);
} }
:root[data-theme="light"] .config-diff__item {
background: white;
}
.config-diff__path { .config-diff__path {
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
@@ -356,6 +384,10 @@
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme="light"] .config-section-hero {
background: var(--bg-hover);
}
.config-section-hero__icon { .config-section-hero__icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
@@ -379,7 +411,7 @@
} }
.config-section-hero__title { .config-section-hero__title {
font-size: 17px; font-size: 16px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
@@ -388,7 +420,7 @@
} }
.config-section-hero__desc { .config-section-hero__desc {
font-size: 14px; font-size: 13px;
color: var(--muted); color: var(--muted);
} }
@@ -402,6 +434,10 @@
overflow-x: auto; overflow-x: auto;
} }
:root[data-theme="light"] .config-subnav {
background: var(--bg-hover);
}
.config-subnav__item { .config-subnav__item {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-full); border-radius: var(--radius-full);
@@ -418,6 +454,10 @@
white-space: nowrap; white-space: nowrap;
} }
:root[data-theme="light"] .config-subnav__item {
background: white;
}
.config-subnav__item:hover { .config-subnav__item:hover {
color: var(--text); color: var(--text);
border-color: var(--border); border-color: var(--border);
@@ -511,6 +551,10 @@
border-color: var(--border-strong); border-color: var(--border-strong);
} }
:root[data-theme="light"] .config-section-card {
background: white;
}
.config-section-card__header { .config-section-card__header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -520,6 +564,10 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
:root[data-theme="light"] .config-section-card__header {
background: var(--bg-hover);
}
.config-section-card__icon { .config-section-card__icon {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -539,7 +587,7 @@
.config-section-card__title { .config-section-card__title {
margin: 0; margin: 0;
font-size: 18px; font-size: 17px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
white-space: nowrap; white-space: nowrap;
@@ -549,7 +597,7 @@
.config-section-card__desc { .config-section-card__desc {
margin: 5px 0 0; margin: 5px 0 0;
font-size: 14px; font-size: 13px;
color: var(--muted); color: var(--muted);
line-height: 1.45; line-height: 1.45;
} }
@@ -576,23 +624,23 @@
padding: 14px; padding: 14px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--danger-subtle); 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 { .cfg-field__label {
font-size: 14px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
} }
.cfg-field__help { .cfg-field__help {
font-size: 13px; font-size: 12px;
color: var(--muted); color: var(--muted);
line-height: 1.45; line-height: 1.45;
} }
.cfg-field__error { .cfg-field__error {
font-size: 13px; font-size: 12px;
color: var(--danger); color: var(--danger);
} }
@@ -627,6 +675,14 @@
background: var(--bg-hover); background: var(--bg-hover);
} }
:root[data-theme="light"] .cfg-input {
background: white;
}
:root[data-theme="light"] .cfg-input:focus {
background: white;
}
.cfg-input--sm { .cfg-input--sm {
padding: 9px 12px; padding: 9px 12px;
font-size: 13px; font-size: 13px;
@@ -677,6 +733,10 @@
box-shadow: var(--focus-ring); box-shadow: var(--focus-ring);
} }
:root[data-theme="light"] .cfg-textarea {
background: white;
}
.cfg-textarea--sm { .cfg-textarea--sm {
padding: 10px 12px; padding: 10px 12px;
font-size: 12px; font-size: 12px;
@@ -691,6 +751,10 @@
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme="light"] .cfg-number {
background: white;
}
.cfg-number__btn { .cfg-number__btn {
width: 44px; width: 44px;
border: none; border: none;
@@ -711,6 +775,14 @@
cursor: not-allowed; 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 { .cfg-number__input {
width: 85px; width: 85px;
padding: 11px; padding: 11px;
@@ -753,6 +825,10 @@
box-shadow: var(--focus-ring); box-shadow: var(--focus-ring);
} }
:root[data-theme="light"] .cfg-select {
background-color: white;
}
/* Segmented Control */ /* Segmented Control */
.cfg-segmented { .cfg-segmented {
display: inline-flex; display: inline-flex;
@@ -762,13 +838,17 @@
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme="light"] .cfg-segmented {
background: var(--bg-hover);
}
.cfg-segmented__btn { .cfg-segmented__btn {
padding: 9px 18px; padding: 9px 18px;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: transparent; background: transparent;
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -818,6 +898,14 @@
cursor: not-allowed; 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 { .cfg-toggle-row__content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -825,7 +913,7 @@
.cfg-toggle-row__label { .cfg-toggle-row__label {
display: block; display: block;
font-size: 15px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--text); color: var(--text);
} }
@@ -833,7 +921,7 @@
.cfg-toggle-row__help { .cfg-toggle-row__help {
display: block; display: block;
margin-top: 3px; margin-top: 3px;
font-size: 13px; font-size: 12px;
color: var(--muted); color: var(--muted);
line-height: 1.45; line-height: 1.45;
} }
@@ -864,6 +952,10 @@
border-color var(--duration-normal) ease; border-color var(--duration-normal) ease;
} }
:root[data-theme="light"] .cfg-toggle__track {
background: var(--border);
}
.cfg-toggle__track::after { .cfg-toggle__track::after {
content: ""; content: "";
position: absolute; position: absolute;
@@ -881,7 +973,7 @@
.cfg-toggle input:checked + .cfg-toggle__track { .cfg-toggle input:checked + .cfg-toggle__track {
background: var(--ok-subtle); 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 { .cfg-toggle input:checked + .cfg-toggle__track::after {
@@ -901,6 +993,10 @@
overflow: hidden; overflow: hidden;
} }
:root[data-theme="light"] .cfg-object {
background: white;
}
.cfg-object__header { .cfg-object__header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -970,6 +1066,10 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
:root[data-theme="light"] .cfg-array__header {
background: var(--bg-hover);
}
.cfg-array__label { .cfg-array__label {
flex: 1; flex: 1;
font-size: 14px; font-size: 14px;
@@ -985,6 +1085,10 @@
border-radius: var(--radius-full); border-radius: var(--radius-full);
} }
:root[data-theme="light"] .cfg-array__count {
background: white;
}
.cfg-array__add { .cfg-array__add {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1052,6 +1156,10 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
:root[data-theme="light"] .cfg-array__item-header {
background: var(--bg-hover);
}
.cfg-array__item-index { .cfg-array__item-index {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -1112,6 +1220,10 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
:root[data-theme="light"] .cfg-map__header {
background: var(--bg-hover);
}
.cfg-map__label { .cfg-map__label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -1208,7 +1320,7 @@
} }
.pill--ok { .pill--ok {
border-color: color-mix(in srgb, var(--ok) 35%, transparent); border-color: rgba(34, 197, 94, 0.35);
color: var(--ok); color: var(--ok);
} }
@@ -1332,85 +1444,3 @@
min-width: 70px; 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 {
--shell-pad: 16px; --shell-pad: 16px;
--shell-gap: 16px; --shell-gap: 16px;
--shell-nav-width: 240px; --shell-nav-width: 220px;
--shell-topbar-height: 62px; --shell-topbar-height: 56px;
--shell-focus-duration: 200ms; --shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out); --shell-focus-ease: var(--ease-out);
height: 100vh; height: 100vh;
@@ -14,7 +14,7 @@
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr); grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) 1fr; grid-template-rows: var(--shell-topbar-height) 1fr;
grid-template-areas: grid-template-areas:
"nav topbar" "topbar topbar"
"nav content"; "nav content";
gap: 0; gap: 0;
animation: dashboard-enter 0.4s var(--ease-out); animation: dashboard-enter 0.4s var(--ease-out);
@@ -41,7 +41,7 @@
} }
.shell--nav-collapsed { .shell--nav-collapsed {
grid-template-columns: 60px minmax(0, 1fr); grid-template-columns: 0px minmax(0, 1fr);
} }
.shell--chat-focus { .shell--chat-focus {
@@ -80,262 +80,139 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 16px;
padding: 0 20px; padding: 0 20px;
height: var(--shell-topbar-height); height: var(--shell-topbar-height);
background: var(--topbar-bg); border-bottom: 1px solid var(--border);
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); background: var(--bg);
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
border-bottom: var(--topbar-border);
} }
/* --- Left: Dashboard Header --- */ .topbar-left {
.dashboard-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 12px;
min-width: 0;
} }
.dashboard-header__breadcrumb { .topbar .nav-collapse-toggle {
display: flex; width: 36px;
align-items: center; height: 36px;
gap: 6px; margin-bottom: 0;
font-size: 0.82rem;
min-width: 0;
} }
.dashboard-header__breadcrumb-link { .topbar .nav-collapse-toggle__icon {
color: var(--muted); width: 20px;
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;
height: 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
flex-shrink: 0; flex-shrink: 0;
} }
.topbar-divider { .brand-logo img {
width: 1px; width: 100%;
height: 20px; height: 100%;
background: var(--border); object-fit: contain;
flex-shrink: 0;
} }
/* Connection indicator */ .brand-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.topbar-connection { .brand-title {
display: inline-flex; font-size: 16px;
align-items: center; font-weight: 700;
gap: 6px; letter-spacing: -0.03em;
padding: 4px 10px; line-height: 1.1;
border-radius: var(--radius-full); color: var(--text-strong);
font-size: 12px; }
.brand-sub {
font-size: 10px;
font-weight: 500; font-weight: 500;
color: var(--danger); color: var(--muted);
background: var(--danger-subtle); letter-spacing: 0.05em;
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 {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1; line-height: 1;
} }
/* Redact / stream-mode toggle */ /* Topbar status */
.topbar-status {
.topbar-redact { display: flex;
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; gap: 8px;
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;
} }
.topbar-redact svg { .topbar-status .pill {
width: 14px; padding: 6px 10px;
height: 14px; gap: 6px;
font-size: 12px;
font-weight: 500;
height: 32px;
box-sizing: border-box;
} }
.topbar-redact:hover { .topbar-status .pill .mono {
color: var(--text); display: flex;
background: color-mix(in srgb, var(--secondary) 80%, transparent); align-items: center;
border-color: var(--border); line-height: 1;
margin-top: 0px;
} }
.topbar-redact--active { .topbar-status .statusDot {
color: var(--warn); 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 { .topbar-status .theme-toggle {
height: 30px; --theme-item: 24px;
--theme-gap: 2px;
--theme-pad: 3px;
} }
.topbar-status .theme-btn svg { .topbar-status .theme-icon {
width: 13px; width: 12px;
height: 13px; height: 12px;
} }
/* =========================================== /* ===========================================
Navigation Sidebar Navigation Sidebar
=========================================== */ =========================================== */
.sidebar { .nav {
grid-area: nav; grid-area: nav;
display: flex;
flex-direction: column;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: none; padding: 16px 12px;
background: var(--sidebar-bg); background: var(--bg);
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); scrollbar-width: none; /* Firefox */
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
transition: transition:
width var(--shell-focus-duration) var(--shell-focus-ease), width var(--shell-focus-duration) var(--shell-focus-ease),
padding 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); opacity var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0; min-height: 0;
border-right: 1px solid var(--glass-border);
} }
.sidebar::-webkit-scrollbar { .nav::-webkit-scrollbar {
display: none; display: none; /* Chrome/Safari */
} }
.shell--chat-focus .sidebar { .shell--chat-focus .nav {
width: 0; width: 0;
padding: 0; padding: 0;
border-width: 0; border-width: 0;
@@ -344,141 +221,51 @@
opacity: 0; opacity: 0;
} }
.sidebar--collapsed { .nav--collapsed {
align-items: center; width: 0;
}
.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;
min-width: 0; min-width: 0;
padding: 0;
max-height: 28px; overflow: hidden;
border: none;
padding-left: 10px; opacity: 0;
padding-right: 10px; pointer-events: none;
@media (max-width: 1100px) {
padding-left: 0;
padding-right: 0;
}
} }
.sidebar-brand__logo { /* Nav collapse toggle */
width: 28px; .nav-collapse-toggle {
height: 28px; width: 32px;
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;
height: 32px; height: 32px;
@media (max-width: 1100px) {
height: 28px;
}
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--bg); background: transparent;
border: var(--border) 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
color: var(--muted);
flex-shrink: 0;
transition: transition:
background var(--duration-fast) ease, background var(--duration-fast) ease,
border-color var(--duration-fast) ease, border-color var(--duration-fast) ease;
color var(--duration-fast) ease; margin-bottom: 16px;
} }
.sidebar--collapsed .sidebar-collapse-btn { .nav-collapse-toggle:hover {
flex: none; background: var(--bg-hover);
width: 100%;
}
.sidebar-collapse-btn:hover {
background: var(--bg);
border-color: var(--border); border-color: var(--border);
color: var(--text);
} }
.sidebar-collapse-btn svg { .nav-collapse-toggle__icon {
width: 24px; display: flex;
height: 24px; 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; stroke: currentColor;
fill: none; fill: none;
stroke-width: 1.5px; stroke-width: 1.5px;
@@ -486,22 +273,13 @@
stroke-linejoin: round; stroke-linejoin: round;
} }
/* Sidebar nav section */ .nav-collapse-toggle:hover .nav-collapse-toggle__icon {
.sidebar-nav { color: var(--text);
flex: 1;
padding: 4px 8px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
} }
/* Nav groups */ /* Nav groups */
.nav-group { .nav-group {
margin-bottom: 16px; margin-bottom: 20px;
display: grid; display: grid;
gap: 2px; gap: 2px;
} }
@@ -519,16 +297,16 @@
display: none; display: none;
} }
/* Nav group label */ /* Nav label */
.nav-group__label { .nav-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 6px 10px; padding: 6px 10px;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 500;
color: var(--muted); color: var(--muted);
margin-bottom: 4px; margin-bottom: 4px;
background: transparent; background: transparent;
@@ -536,40 +314,37 @@
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.04em;
transition: transition:
color var(--duration-fast) ease, color var(--duration-fast) ease,
background var(--duration-fast) ease; background var(--duration-fast) ease;
} }
.nav-group__label:hover { .nav-label:hover {
color: var(--text); color: var(--text);
background: var(--bg-hover); 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; flex: 1;
} }
.nav-group__chevron { .nav-label__chevron {
display: flex; font-size: 10px;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
opacity: 0.5; opacity: 0.5;
transition: transform var(--duration-fast) ease; transition: transform var(--duration-fast) ease;
} }
.nav-group__chevron svg { .nav-group--collapsed .nav-label__chevron {
width: 12px; transform: rotate(-90deg);
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
stroke-linecap: round;
stroke-linejoin: round;
} }
/* Nav items */ /* Nav items */
@@ -579,7 +354,7 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
padding: 9px 12px; padding: 8px 10px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
@@ -589,13 +364,12 @@
transition: transition:
border-color var(--duration-fast) ease, border-color var(--duration-fast) ease,
background var(--duration-fast) ease, background var(--duration-fast) ease,
color var(--duration-fast) ease, color var(--duration-fast) ease;
box-shadow var(--duration-fast) ease;
} }
.nav-item__icon { .nav-item__icon {
width: 18px; width: 16px;
height: 18px; height: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -605,8 +379,8 @@
} }
.nav-item__icon svg { .nav-item__icon svg {
width: 18px; width: 16px;
height: 18px; height: 16px;
stroke: currentColor; stroke: currentColor;
fill: none; fill: none;
stroke-width: 1.5px; stroke-width: 1.5px;
@@ -615,32 +389,14 @@
} }
.nav-item__text { .nav-item__text {
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
white-space: nowrap; 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 { .nav-item:hover {
color: var(--text); color: var(--text);
background: color-mix(in srgb, var(--secondary) 90%, transparent); background: var(--bg-hover);
border-color: color-mix(in srgb, var(--border) 75%, transparent);
text-decoration: none; text-decoration: none;
} }
@@ -648,55 +404,23 @@
opacity: 1; opacity: 1;
} }
.nav-item--active { .nav-item.active {
color: var(--text-strong); color: var(--text-strong);
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--secondary)); background: var(--accent-subtle);
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);
} }
.nav-item--active .nav-item__icon { .nav-item.active .nav-item__icon {
opacity: 1; opacity: 1;
color: var(--accent); 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 Area
=========================================== */ =========================================== */
.content { .content {
grid-area: content; grid-area: content;
padding: 14px 18px 36px; padding: 12px 16px 32px;
display: block; display: block;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -707,6 +431,10 @@
margin-top: 24px; margin-top: 24px;
} }
:root[data-theme="light"] .content {
background: var(--bg-content);
}
.content--chat { .content--chat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -725,7 +453,7 @@
align-items: flex-end; align-items: flex-end;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 4px 0; padding: 4px 8px;
overflow: hidden; overflow: hidden;
transform-origin: top center; transform-origin: top center;
transition: transition:
@@ -745,7 +473,7 @@
} }
.page-title { .page-title {
font-size: 28px; font-size: 26px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.035em; letter-spacing: -0.035em;
line-height: 1.15; line-height: 1.15;
@@ -754,7 +482,7 @@
.page-sub { .page-sub {
color: var(--muted); color: var(--muted);
font-size: 15px; font-size: 14px;
font-weight: 400; font-weight: 400;
margin-top: 6px; margin-top: 6px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
@@ -849,31 +577,16 @@
"content"; "content";
} }
.sidebar { .nav {
position: static; position: static;
max-height: none; max-height: none;
display: flex; display: flex;
flex-direction: row;
gap: 6px; gap: 6px;
overflow-x: auto; overflow-x: auto;
border-right: none; border-right: none;
border-bottom: 1px solid var(--border); 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; padding: 10px 14px;
overflow-x: auto; background: var(--bg);
} }
.nav-group { .nav-group {
@@ -893,12 +606,8 @@
gap: 10px; gap: 10px;
} }
.topbar-search__kbd {
display: none;
}
.topbar-status { .topbar-status {
flex-wrap: nowrap; flex-wrap: wrap;
} }
.table-head, .table-head,

View File

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

View File

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

View File

@@ -24,7 +24,6 @@ import {
parseExecApprovalResolved, parseExecApprovalResolved,
removeExecApproval, removeExecApproval,
} from "./controllers/exec-approval.ts"; } from "./controllers/exec-approval.ts";
import { loadHealthState } from "./controllers/health.ts";
import { loadNodes } from "./controllers/nodes.ts"; import { loadNodes } from "./controllers/nodes.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
@@ -34,7 +33,7 @@ import type { UiSettings } from "./storage.ts";
import type { import type {
AgentsListResult, AgentsListResult,
PresenceEntry, PresenceEntry,
HealthSummary, HealthSnapshot,
StatusSummary, StatusSummary,
UpdateAvailable, UpdateAvailable,
} from "./types.ts"; } from "./types.ts";
@@ -56,10 +55,7 @@ type GatewayHost = {
agentsLoading: boolean; agentsLoading: boolean;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsError: string | null; agentsError: string | null;
healthLoading: boolean; debugHealth: HealthSnapshot | null;
healthResult: HealthSummary | null;
healthError: string | null;
debugHealth: HealthSummary | null;
assistantName: string; assistantName: string;
assistantAvatar: string | null; assistantAvatar: string | null;
assistantAgentId: string | null; assistantAgentId: string | null;
@@ -160,7 +156,6 @@ export function connectGateway(host: GatewayHost) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAssistantIdentity(host as unknown as OpenClawApp);
void loadAgents(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 loadNodes(host as unknown as OpenClawApp, { quiet: true });
void loadDevices(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]); 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 }, { ts: Date.now(), event: evt.event, payload: evt.payload },
...host.eventLogBuffer, ...host.eventLogBuffer,
].slice(0, 250); ].slice(0, 250);
if (host.tab === "debug" || host.tab === "overview") { if (host.tab === "debug") {
host.eventLog = host.eventLogBuffer; host.eventLog = host.eventLogBuffer;
} }
@@ -298,7 +293,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as const snapshot = hello.snapshot as
| { | {
presence?: PresenceEntry[]; presence?: PresenceEntry[];
health?: HealthSummary; health?: HealthSnapshot;
sessionDefaults?: SessionDefaultsSnapshot; sessionDefaults?: SessionDefaultsSnapshot;
updateAvailable?: UpdateAvailable; updateAvailable?: UpdateAvailable;
} }
@@ -308,7 +303,6 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
} }
if (snapshot?.health) { if (snapshot?.health) {
host.debugHealth = snapshot.health; host.debugHealth = snapshot.health;
host.healthResult = snapshot.health;
} }
if (snapshot?.sessionDefaults) { if (snapshot?.sessionDefaults) {
applySessionDefaults(host, snapshot.sessionDefaults); applySessionDefaults(host, snapshot.sessionDefaults);

View File

@@ -10,6 +10,8 @@ import {
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts"; import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
import { import {
applySettingsFromUrl, applySettingsFromUrl,
attachThemeListener,
detachThemeListener,
inferBasePath, inferBasePath,
syncTabWithLocation, syncTabWithLocation,
syncThemeWithSettings, syncThemeWithSettings,
@@ -36,28 +38,14 @@ type LifecycleHost = {
topbarObserver: ResizeObserver | null; 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) { export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath(); host.basePath = inferBasePath();
void loadControlUiBootstrapConfig(host); void loadControlUiBootstrapConfig(host);
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]); applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true); syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]); syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler); 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]); connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]); startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") { if (host.tab === "logs") {
@@ -74,13 +62,10 @@ export function handleFirstUpdated(host: LifecycleHost) {
export function handleDisconnected(host: LifecycleHost) { export function handleDisconnected(host: LifecycleHost) {
window.removeEventListener("popstate", host.popStateHandler); 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]); stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]); stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]); stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
host.topbarObserver?.disconnect(); host.topbarObserver?.disconnect();
host.topbarObserver = null; 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 { repeat } from "lit/directives/repeat.js";
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts"; import { refreshChat } from "./app-chat.ts";
@@ -49,12 +49,10 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
export function renderTab(state: AppViewState, tab: Tab) { export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath); const href = pathForTab(tab, state.basePath);
const isActive = state.tab === tab;
const collapsed = state.settings.navCollapsed;
return html` return html`
<a <a
href=${href} href=${href}
class="nav-item ${isActive ? "nav-item--active" : ""}" class="nav-item ${state.tab === tab ? "active" : ""}"
@click=${(event: MouseEvent) => { @click=${(event: MouseEvent) => {
if ( if (
event.defaultPrevented || event.defaultPrevented ||
@@ -79,7 +77,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
title=${titleForTab(tab)} title=${titleForTab(tab)}
> >
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span> <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> </a>
`; `;
} }
@@ -396,18 +394,10 @@ function resolveSessionOptions(
return options; return options;
} }
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons }; const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
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" },
];
export function renderThemeToggle(state: AppViewState) { 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 applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
const element = event.currentTarget as HTMLElement; const element = event.currentTarget as HTMLElement;
const context: ThemeTransitionContext = { element }; const context: ThemeTransitionContext = { element };
@@ -418,34 +408,74 @@ export function renderThemeToggle(state: AppViewState) {
state.setTheme(next, context); state.setTheme(next, context);
}; };
const handleCollapse = () => app.handleThemeToggleCollapse();
return html` return html`
<div <div class="theme-toggle" style="--theme-index: ${index};">
class="theme-toggle" <div class="theme-toggle__track" role="group" aria-label="Theme">
@mouseleave=${handleCollapse} <span class="theme-toggle__indicator"></span>
@focusout=${(e: FocusEvent) => { <button
const toggle = e.currentTarget as HTMLElement; class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
requestAnimationFrame(() => { @click=${applyTheme("system")}
if (!toggle.contains(document.activeElement)) { aria-pressed=${state.theme === "system"}
handleCollapse(); aria-label="System theme"
} title="System"
}); >
}} ${renderMonitorIcon()}
> </button>
${state.themeOrder.map((id) => { <button
const opt = THEME_OPTIONS.find((o) => o.id === id)!; class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
return html` @click=${applyTheme("light")}
<button aria-pressed=${state.theme === "light"}
class="theme-btn ${state.theme === id ? "active" : ""}" aria-label="Light theme"
@click=${applyTheme(id)} title="Light"
aria-pressed=${state.theme === id} >
title=${opt.label} ${renderSunIcon()}
> </button>
${icons[opt.iconKey]} <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> </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 { html, nothing } from "lit";
import { import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
buildAgentMainSessionKey,
parseAgentSessionKey,
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { refreshChatAvatar } from "./app-chat.ts"; import { refreshChatAvatar } from "./app-chat.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts";
@@ -55,21 +52,17 @@ import {
updateSkillEdit, updateSkillEdit,
updateSkillEnabled, updateSkillEnabled,
} from "./controllers/skills.ts"; } from "./controllers/skills.ts";
import "./components/dashboard-header.ts";
import { icons } from "./icons.ts"; import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { renderAgents } from "./views/agents.ts"; import { renderAgents } from "./views/agents.ts";
import { renderBottomTabs } from "./views/bottom-tabs.ts";
import { renderChannels } from "./views/channels.ts"; import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts"; import { renderChat } from "./views/chat.ts";
import { renderCommandPalette } from "./views/command-palette.ts";
import { renderConfig } from "./views/config.ts"; import { renderConfig } from "./views/config.ts";
import { renderCron } from "./views/cron.ts"; import { renderCron } from "./views/cron.ts";
import { renderDebug } from "./views/debug.ts"; import { renderDebug } from "./views/debug.ts";
import { renderExecApprovalPrompt } from "./views/exec-approval.ts"; import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts"; import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
import { renderInstances } from "./views/instances.ts"; import { renderInstances } from "./views/instances.ts";
import { renderLoginGate } from "./views/login-gate.ts";
import { renderLogs } from "./views/logs.ts"; import { renderLogs } from "./views/logs.ts";
import { renderNodes } from "./views/nodes.ts"; import { renderNodes } from "./views/nodes.ts";
import { renderOverview } from "./views/overview.ts"; import { renderOverview } from "./views/overview.ts";
@@ -96,15 +89,6 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
} }
export function renderApp(state: AppViewState) { 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 presenceCount = state.presenceEntries.length;
const sessionsCount = state.sessionsResult?.count ?? null; const sessionsCount = state.sessionsResult?.count ?? null;
const cronNext = state.cronStatus?.nextWakeAtMs ?? null; const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
@@ -124,165 +108,83 @@ export function renderApp(state: AppViewState) {
null; null;
return html` 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" : ""}"> <div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
<header class="topbar"> <header class="topbar">
<dashboard-header .tab=${state.tab}></dashboard-header> <div class="topbar-left">
<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">
<button <button
class="topbar-redact ${state.streamMode ? "topbar-redact--active" : ""}" class="nav-collapse-toggle"
@click=${() => { @click=${() =>
state.streamMode = !state.streamMode; state.applySettings({
try { ...state.settings,
localStorage.setItem("openclaw:stream-mode", String(state.streamMode)); navCollapsed: !state.settings.navCollapsed,
} catch { })}
/* */ title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
} aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
}}
title="${state.streamMode ? "Sensitive data hidden — click to reveal" : "Sensitive data visible — click to hide"}"
aria-label="Toggle redaction"
aria-pressed=${state.streamMode}
> >
${state.streamMode ? icons.eye : icons.eyeOff} <span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button> </button>
<span class="topbar-divider"></span> <div class="brand">
<div class="topbar-connection ${state.connected ? "topbar-connection--ok" : ""}"> <div class="brand-logo">
<span class="topbar-connection__dot"></span> <img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
<span class="topbar-connection__label">${state.connected ? t("common.ok") : t("common.offline")}</span> </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> </div>
<span class="topbar-divider"></span>
${renderThemeToggle(state)} ${renderThemeToggle(state)}
</div> </div>
</header> </header>
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}"> <aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
<div class="sidebar-header"> ${TAB_GROUPS.map((group) => {
${ const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
state.settings.navCollapsed const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
? nothing return html`
: html` <div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
<div class="sidebar-brand"> <button
<img class="sidebar-brand__logo" src="${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"}" alt="OpenClaw" /> class="nav-label"
<span class="sidebar-brand__title">OpenClaw</span> @click=${() => {
</div> const next = { ...state.settings.navGroupsCollapsed };
` next[group.label] = !isGroupCollapsed;
} state.applySettings({
<button ...state.settings,
class="sidebar-collapse-btn" navGroupsCollapsed: next,
@click=${() => });
state.applySettings({ }}
...state.settings, aria-expanded=${!isGroupCollapsed}
navCollapsed: !state.settings.navCollapsed, >
})} <span class="nav-label__text">${t(`nav.${group.label}`)}</span>
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}" <span class="nav-label__chevron">${isGroupCollapsed ? "+" : ""}</span>
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}" </button>
> <div class="nav-group__items">
${state.settings.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose} ${group.tabs.map((tab) => renderTab(state, tab))}
</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>
</div> </div>
`; </div>
})} `;
</nav> })}
<div class="nav-group nav-group--links">
<div class="sidebar-footer"> <div class="nav-label nav-label--static">
<a <span class="nav-label__text">${t("common.resources")}</span>
class="nav-item nav-item--external" </div>
href="https://docs.openclaw.ai" <div class="nav-group__items">
target="_blank" <a
rel="noreferrer" class="nav-item nav-item--external"
title="${t("common.docs")} (opens in new tab)" href="https://docs.openclaw.ai"
> target="_blank"
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> rel="noreferrer"
${ title="${t("common.docs")} (opens in new tab)"
!state.settings.navCollapsed >
? html` <span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
<span class="nav-item__text">${t("common.docs")}</span> <span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span> </a>
` </div>
: 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;
})()}
</div> </div>
</aside> </aside>
<main class="content ${isChat ? "content--chat" : ""}"> <main class="content ${isChat ? "content--chat" : ""}">
@@ -323,15 +225,6 @@ export function renderApp(state: AppViewState) {
cronEnabled: state.cronStatus?.enabled ?? null, cronEnabled: state.cronStatus?.enabled ?? null,
cronNext, cronNext,
lastChannelsRefresh: state.channelsLastSuccess, 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), onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next), onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => { onSessionKeyChange: (next) => {
@@ -347,16 +240,6 @@ export function renderApp(state: AppViewState) {
}, },
onConnect: () => state.connect(), onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(), 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 : nothing
} }
@@ -407,7 +290,6 @@ export function renderApp(state: AppViewState) {
entries: state.presenceEntries, entries: state.presenceEntries,
lastError: state.presenceError, lastError: state.presenceError,
statusMessage: state.presenceStatus, statusMessage: state.presenceStatus,
streamMode: state.streamMode,
onRefresh: () => loadPresence(state), onRefresh: () => loadPresence(state),
}) })
: nothing : nothing
@@ -476,47 +358,33 @@ export function renderApp(state: AppViewState) {
agentsList: state.agentsList, agentsList: state.agentsList,
selectedAgentId: resolvedAgentId, selectedAgentId: resolvedAgentId,
activePanel: state.agentsPanel, activePanel: state.agentsPanel,
config: { configForm: configValue,
form: configValue, configLoading: state.configLoading,
loading: state.configLoading, configSaving: state.configSaving,
saving: state.configSaving, configDirty: state.configFormDirty,
dirty: state.configFormDirty, channelsLoading: state.channelsLoading,
}, channelsError: state.channelsError,
channels: { channelsSnapshot: state.channelsSnapshot,
snapshot: state.channelsSnapshot, channelsLastSuccess: state.channelsLastSuccess,
loading: state.channelsLoading, cronLoading: state.cronLoading,
error: state.channelsError, cronStatus: state.cronStatus,
lastSuccess: state.channelsLastSuccess, cronJobs: state.cronJobs,
}, cronError: state.cronError,
cron: { agentFilesLoading: state.agentFilesLoading,
status: state.cronStatus, agentFilesError: state.agentFilesError,
jobs: state.cronJobs, agentFilesList: state.agentFilesList,
loading: state.cronLoading, agentFileActive: state.agentFileActive,
error: state.cronError, agentFileContents: state.agentFileContents,
}, agentFileDrafts: state.agentFileDrafts,
agentFiles: { agentFileSaving: state.agentFileSaving,
list: state.agentFilesList,
loading: state.agentFilesLoading,
error: state.agentFilesError,
active: state.agentFileActive,
contents: state.agentFileContents,
drafts: state.agentFileDrafts,
saving: state.agentFileSaving,
},
agentIdentityLoading: state.agentIdentityLoading, agentIdentityLoading: state.agentIdentityLoading,
agentIdentityError: state.agentIdentityError, agentIdentityError: state.agentIdentityError,
agentIdentityById: state.agentIdentityById, agentIdentityById: state.agentIdentityById,
agentSkills: { agentSkillsLoading: state.agentSkillsLoading,
report: state.agentSkillsReport, agentSkillsReport: state.agentSkillsReport,
loading: state.agentSkillsLoading, agentSkillsError: state.agentSkillsError,
error: state.agentSkillsError, agentSkillsAgentId: state.agentSkillsAgentId,
agentId: state.agentSkillsAgentId, skillsFilter: state.skillsFilter,
filter: state.skillsFilter,
},
sidebarFilter: state.agentsSidebarFilter,
onSidebarFilterChange: (value) => {
state.agentsSidebarFilter = value;
},
onRefresh: async () => { onRefresh: async () => {
await loadAgents(state); await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
@@ -655,9 +523,6 @@ export function renderApp(state: AppViewState) {
onConfigSave: () => saveConfig(state), onConfigSave: () => saveConfig(state),
onChannelsRefresh: () => loadChannels(state, false), onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(), onCronRefresh: () => state.loadCron(),
onCronRunNow: (_jobId) => {
// Stub: backend support pending
},
onSkillsFilterChange: (next) => (state.skillsFilter = next), onSkillsFilterChange: (next) => (state.skillsFilter = next),
onSkillsRefresh: () => { onSkillsRefresh: () => {
if (resolvedAgentId) { if (resolvedAgentId) {
@@ -827,12 +692,6 @@ export function renderApp(state: AppViewState) {
: { fallbacks: normalized }; : { fallbacks: normalized };
updateConfigFormValue(state, basePath, next); updateConfigFormValue(state, basePath, next);
}, },
onSetDefault: (agentId) => {
if (!configValue) {
return;
}
updateConfigFormValue(state, ["agents", "defaultId"], agentId);
},
}) })
: nothing : nothing
} }
@@ -1001,45 +860,6 @@ export function renderApp(state: AppViewState) {
onAbort: () => void state.handleAbortChat(), onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id), onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }), 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, showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
onScrollToBottom: () => state.scrollToBottom(), onScrollToBottom: () => state.scrollToBottom(),
// Sidebar props for tool output viewing // Sidebar props for tool output viewing
@@ -1077,7 +897,6 @@ export function renderApp(state: AppViewState) {
searchQuery: state.configSearchQuery, searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection, activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection, activeSubsection: state.configActiveSubsection,
streamMode: state.streamMode,
onRawChange: (next) => { onRawChange: (next) => {
state.configRaw = next; state.configRaw = next;
}, },
@@ -1143,10 +962,6 @@ export function renderApp(state: AppViewState) {
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)} ${renderGatewayUrlConfirmation(state)}
${renderBottomTabs({
activeTab: state.tab,
onTabChange: (tab) => state.setTab(tab),
})}
</div> </div>
`; `;
} }

View File

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

View File

@@ -21,7 +21,6 @@ import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts"; import { loadPresence } from "./controllers/presence.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import { loadSkills } from "./controllers/skills.ts"; import { loadSkills } from "./controllers/skills.ts";
import { loadUsage } from "./controllers/usage.ts";
import { import {
inferBasePathFromPathname, inferBasePathFromPathname,
normalizeBasePath, normalizeBasePath,
@@ -33,7 +32,7 @@ import {
import { saveSettings, type UiSettings } from "./storage.ts"; import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.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 = { type SettingsHost = {
settings: UiSettings; settings: UiSettings;
@@ -52,6 +51,8 @@ type SettingsHost = {
agentsList?: AgentsListResult | null; agentsList?: AgentsListResult | null;
agentsSelectedId?: string | null; agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null; pendingGatewayUrl?: string | null;
}; };
@@ -258,7 +259,7 @@ export function inferBasePath() {
} }
export function syncThemeWithSettings(host: SettingsHost) { export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "dark"; host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme)); applyResolvedTheme(host, resolveTheme(host.theme));
} }
@@ -269,7 +270,44 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
} }
const root = document.documentElement; const root = document.documentElement;
root.dataset.theme = resolved; 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) { 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) { export async function loadOverview(host: SettingsHost) {
const app = host as unknown as OpenClawApp; await Promise.all([
await Promise.allSettled([ loadChannels(host as unknown as OpenClawApp, false),
loadChannels(app, false), loadPresence(host as unknown as OpenClawApp),
loadPresence(app), loadSessions(host as unknown as OpenClawApp),
loadSessions(app), loadCronStatus(host as unknown as OpenClawApp),
loadCronStatus(app), loadDebug(host as unknown as OpenClawApp),
loadCronJobs(app),
loadDebug(app),
loadSkills(app),
loadUsage(app),
loadOverviewLogs(app),
]); ]);
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) { 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 { Tab } from "./navigation.ts";
import type { UiSettings } from "./storage.ts"; import type { UiSettings } from "./storage.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ResolvedTheme, ThemeMode } from "./theme.ts"; import type { ThemeMode } from "./theme.ts";
import type { import type {
AgentsListResult, AgentsListResult,
AgentsFilesListResult, AgentsFilesListResult,
AgentIdentityResult, AgentIdentityResult,
AttentionItem,
ChannelsStatusSnapshot, ChannelsStatusSnapshot,
ConfigSnapshot, ConfigSnapshot,
ConfigUiHints, ConfigUiHints,
CronJob, CronJob,
CronRunLogEntry, CronRunLogEntry,
CronStatus, CronStatus,
HealthSummary, HealthSnapshot,
LogEntry, LogEntry,
LogLevel, LogLevel,
ModelCatalogEntry,
NostrProfile, NostrProfile,
PresenceEntry, PresenceEntry,
SessionsUsageResult, SessionsUsageResult,
@@ -45,8 +43,7 @@ export type AppViewState = {
basePath: string; basePath: string;
connected: boolean; connected: boolean;
theme: ThemeMode; theme: ThemeMode;
themeResolved: ResolvedTheme; themeResolved: "light" | "dark";
themeOrder: ThemeMode[];
hello: GatewayHelloOk | null; hello: GatewayHelloOk | null;
lastError: string | null; lastError: string | null;
eventLog: EventLogEntry[]; eventLog: EventLogEntry[];
@@ -146,7 +143,6 @@ export type AppViewState = {
agentSkillsError: string | null; agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null; agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null; agentSkillsAgentId: string | null;
agentsSidebarFilter: string;
sessionsLoading: boolean; sessionsLoading: boolean;
sessionsResult: SessionsListResult | null; sessionsResult: SessionsListResult | null;
sessionsError: string | null; sessionsError: string | null;
@@ -204,13 +200,10 @@ export type AppViewState = {
skillEdits: Record<string, string>; skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>; skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null; skillsBusyKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
debugLoading: boolean; debugLoading: boolean;
debugStatus: StatusSummary | null; debugStatus: StatusSummary | null;
debugHealth: HealthSummary | null; debugHealth: HealthSnapshot | null;
debugModels: ModelCatalogEntry[]; debugModels: unknown[];
debugHeartbeat: unknown; debugHeartbeat: unknown;
debugCallMethod: string; debugCallMethod: string;
debugCallParams: string; debugCallParams: string;
@@ -230,12 +223,6 @@ export type AppViewState = {
logsMaxBytes: number; logsMaxBytes: number;
logsAtBottom: boolean; logsAtBottom: boolean;
updateAvailable: import("./types.js").UpdateAvailable | null; updateAvailable: import("./types.js").UpdateAvailable | null;
// Overview dashboard state
attentionItems: AttentionItem[];
paletteOpen: boolean;
streamMode: boolean;
overviewLogLines: string[];
overviewLogCursor: number;
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
refreshSessionsAfterChat: Set<string>; refreshSessionsAfterChat: Set<string>;
connect: () => void; connect: () => void;

View File

@@ -60,7 +60,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts"; import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.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 { import type {
AgentsListResult, AgentsListResult,
AgentsFilesListResult, AgentsFilesListResult,
@@ -70,10 +70,9 @@ import type {
CronJob, CronJob,
CronRunLogEntry, CronRunLogEntry,
CronStatus, CronStatus,
HealthSummary, HealthSnapshot,
LogEntry, LogEntry,
LogLevel, LogLevel,
ModelCatalogEntry,
PresenceEntry, PresenceEntry,
ChannelsStatusSnapshot, ChannelsStatusSnapshot,
SessionsListResult, SessionsListResult,
@@ -119,9 +118,8 @@ export class OpenClawApp extends LitElement {
@state() tab: Tab = "chat"; @state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode(); @state() onboarding = resolveOnboardingMode();
@state() connected = false; @state() connected = false;
@state() theme: ThemeMode = this.settings.theme ?? "dark"; @state() theme: ThemeMode = this.settings.theme ?? "system";
@state() themeResolved: ResolvedTheme = "dark"; @state() themeResolved: ResolvedTheme = "dark";
@state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null; @state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null; @state() lastError: string | null = null;
@state() eventLog: EventLogEntry[] = []; @state() eventLog: EventLogEntry[] = [];
@@ -231,7 +229,6 @@ export class OpenClawApp extends LitElement {
@state() agentSkillsError: string | null = null; @state() agentSkillsError: string | null = null;
@state() agentSkillsReport: SkillStatusReport | null = null; @state() agentSkillsReport: SkillStatusReport | null = null;
@state() agentSkillsAgentId: string | null = null; @state() agentSkillsAgentId: string | null = null;
@state() agentsSidebarFilter = "";
@state() sessionsLoading = false; @state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null; @state() sessionsResult: SessionsListResult | null = null;
@@ -307,23 +304,6 @@ export class OpenClawApp extends LitElement {
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null; @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() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null; @state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null; @state() skillsError: string | null = null;
@@ -332,14 +312,10 @@ export class OpenClawApp extends LitElement {
@state() skillsBusyKey: string | null = null; @state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {}; @state() skillMessages: Record<string, SkillMessage> = {};
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;
@state() healthError: string | null = null;
@state() debugLoading = false; @state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null; @state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSummary | null = null; @state() debugHealth: HealthSnapshot | null = null;
@state() debugModels: ModelCatalogEntry[] = []; @state() debugModels: unknown[] = [];
@state() debugHeartbeat: unknown = null; @state() debugHeartbeat: unknown = null;
@state() debugCallMethod = ""; @state() debugCallMethod = "";
@state() debugCallParams = "{}"; @state() debugCallParams = "{}";
@@ -378,6 +354,8 @@ export class OpenClawApp extends LitElement {
basePath = ""; basePath = "";
private popStateHandler = () => private popStateHandler = () =>
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]); onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null; private topbarObserver: ResizeObserver | null = null;
createRenderRoot() { createRenderRoot() {
@@ -455,19 +433,6 @@ export class OpenClawApp extends LitElement {
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context); 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() { 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 { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity.ts"; import type { AssistantIdentity } from "../assistant-identity.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { detectTextDirection } from "../text-direction.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 { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import { import {
extractTextCached, extractTextCached,
@@ -112,7 +111,6 @@ export function renderMessageGroup(
showReasoning: boolean; showReasoning: boolean;
assistantName?: string; assistantName?: string;
assistantAvatar?: string | null; assistantAvatar?: string | null;
onDelete?: () => void;
}, },
) { ) {
const normalizedRole = normalizeRoleForGrouping(group.role); const normalizedRole = normalizeRoleForGrouping(group.role);
@@ -150,16 +148,6 @@ export function renderMessageGroup(
<div class="chat-group-footer"> <div class="chat-group-footer">
<span class="chat-sender-name">${who}</span> <span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</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> </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( function renderGroupedMessage(
message: unknown, message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean }, opts: { isStreaming: boolean; showReasoning: boolean },
@@ -315,9 +243,6 @@ function renderGroupedMessage(
const markdown = markdownBase; const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); 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 = [ const bubbleClasses = [
"chat-bubble", "chat-bubble",
canCopyMarkdown ? "has-copy" : "", canCopyMarkdown ? "has-copy" : "",
@@ -328,7 +253,7 @@ function renderGroupedMessage(
.join(" "); .join(" ");
if (!markdown && hasToolCards && isToolResult) { if (!markdown && hasToolCards && isToolResult) {
return renderCollapsedToolCards(toolCards, onOpenSidebar); return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
} }
if (!markdown && !hasToolCards && !hasImages) { if (!markdown && !hasToolCards && !hasImages) {
@@ -347,19 +272,11 @@ function renderGroupedMessage(
: nothing : nothing
} }
${ ${
jsonResult markdown
? html`<details class="chat-json-collapse"> ? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
<summary class="chat-json-summary"> : nothing
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
} }
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing} ${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div> </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"); expect(container.textContent).toContain("Plugin Enabled");
}); });
it("passes mixed unions through for JSON fallback rendering", () => { it("flags unsupported unions", () => {
const schema = { const schema = {
type: "object", type: "object",
properties: { properties: {
@@ -207,7 +207,7 @@ describe("config form renderer", () => {
}, },
}; };
const analysis = analyzeConfigSchema(schema); const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("mixed"); expect(analysis.unsupportedPaths).toContain("mixed");
}); });
it("supports nullable types", () => { it("supports nullable types", () => {

View File

@@ -1,24 +1,18 @@
import type { GatewayBrowserClient } from "../gateway.ts"; import type { GatewayBrowserClient } from "../gateway.ts";
import type { HealthSummary, ModelCatalogEntry, StatusSummary } from "../types.ts"; import type { HealthSnapshot, StatusSummary } from "../types.ts";
import { loadHealthState } from "./health.ts";
import { loadModels } from "./models.ts";
export type DebugState = { export type DebugState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
debugLoading: boolean; debugLoading: boolean;
debugStatus: StatusSummary | null; debugStatus: StatusSummary | null;
debugHealth: HealthSummary | null; debugHealth: HealthSnapshot | null;
debugModels: ModelCatalogEntry[]; debugModels: unknown[];
debugHeartbeat: unknown; debugHeartbeat: unknown;
debugCallMethod: string; debugCallMethod: string;
debugCallParams: string; debugCallParams: string;
debugCallResult: string | null; debugCallResult: string | null;
debugCallError: 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) { export async function loadDebug(state: DebugState) {
@@ -30,16 +24,16 @@ export async function loadDebug(state: DebugState) {
} }
state.debugLoading = true; state.debugLoading = true;
try { try {
const [status, , models, heartbeat] = await Promise.all([ const [status, health, models, heartbeat] = await Promise.all([
state.client.request("status", {}), state.client.request("status", {}),
loadHealthState(state), state.client.request("health", {}),
loadModels(state.client), state.client.request("models.list", {}),
state.client.request("last-heartbeat", {}), state.client.request("last-heartbeat", {}),
]); ]);
state.debugStatus = status as StatusSummary; state.debugStatus = status as StatusSummary;
// Sync debugHealth from the shared healthResult for backward compat. state.debugHealth = health as HealthSnapshot;
state.debugHealth = state.healthResult; const modelPayload = models as { models?: unknown[] } | undefined;
state.debugModels = models; state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : [];
state.debugHeartbeat = heartbeat; state.debugHeartbeat = heartbeat;
} catch (err) { } catch (err) {
state.debugCallError = String(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 { export function stripThinkingTags(value: string): string {
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); 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") // 4008 = application-defined code (browser rejects 1008 "Policy Violation")
const CONNECT_FAILED_CLOSE_CODE = 4008; const CONNECT_FAILED_CLOSE_CODE = 4008;
const DEFAULT_OPERATOR_CONNECT_SCOPES = [
"operator.admin",
"operator.read",
"operator.write",
"operator.approvals",
"operator.pairing",
];
export class GatewayBrowserClient { export class GatewayBrowserClient {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
@@ -136,11 +129,6 @@ export class GatewayBrowserClient {
if (this.connectSent) { if (this.connectSent) {
return; return;
} }
const nonce = this.connectNonce?.trim() ?? "";
if (!nonce) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce");
return;
}
this.connectSent = true; this.connectSent = true;
if (this.connectTimer !== null) { if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer); window.clearTimeout(this.connectTimer);
@@ -152,9 +140,10 @@ export class GatewayBrowserClient {
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled. // Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle; const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES; const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator"; const role = "operator";
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null; let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token; let authToken = this.opts.token;
if (isSecureContext) { if (isSecureContext) {
@@ -164,6 +153,7 @@ export class GatewayBrowserClient {
role, role,
})?.token; })?.token;
authToken = storedToken ?? this.opts.token; authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
} }
const auth = const auth =
authToken || this.opts.password authToken || this.opts.password
@@ -179,12 +169,13 @@ export class GatewayBrowserClient {
publicKey: string; publicKey: string;
signature: string; signature: string;
signedAt: number; signedAt: number;
nonce: string; nonce: string | undefined;
} }
| undefined; | undefined;
if (isSecureContext && deviceIdentity) { if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now(); const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({ const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId, deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI, clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
@@ -237,11 +228,7 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello); this.opts.onHello?.(hello);
}) })
.catch(() => { .catch(() => {
// Clear stale device token on any connect failure so the next attempt if (canFallbackToShared && deviceIdentity) {
// 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) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role }); clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
} }
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed"); this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
@@ -262,12 +249,10 @@ export class GatewayBrowserClient {
if (evt.event === "connect.challenge") { if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined; const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null; const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (!nonce || nonce.trim().length === 0) { if (nonce) {
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge missing nonce"); this.connectNonce = nonce;
return; void this.sendConnect();
} }
this.connectNonce = nonce.trim();
void this.sendConnect();
return; return;
} }
const seq = typeof evt.seq === "number" ? evt.seq : null; const seq = typeof evt.seq === "number" ? evt.seq : null;
@@ -321,10 +306,7 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer); window.clearTimeout(this.connectTimer);
} }
this.connectTimer = window.setTimeout(() => { this.connectTimer = window.setTimeout(() => {
if (this.connectSent || this.ws?.readyState !== WebSocket.OPEN) { void this.sendConnect();
return; }, 750);
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect challenge timeout");
}, 2_000);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,16 @@
export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash"; export type ThemeMode = "system" | "light" | "dark";
export type ResolvedTheme = ThemeMode; export type ResolvedTheme = "light" | "dark";
export const VALID_THEMES = new Set<ThemeMode>([ export function getSystemTheme(): ResolvedTheme {
"dark", if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
"light", return "dark";
"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;
} }
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>; 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 LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = { export type LogEntry = {
@@ -595,16 +566,3 @@ export type LogEntry = {
message?: string | null; message?: string | null;
meta?: Record<string, unknown> | 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 const status = summary.total
? `${summary.connected}/${summary.total} connected` ? `${summary.connected}/${summary.total} connected`
: "no accounts"; : "no accounts";
const configLabel = summary.configured const config = summary.configured
? `${summary.configured} configured` ? `${summary.configured} configured`
: "not configured"; : "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled"; const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
@@ -243,23 +243,8 @@ export function renderAgentChannels(params: {
</div> </div>
<div class="list-meta"> <div class="list-meta">
<div>${status}</div> <div>${status}</div>
<div>${configLabel}</div> <div>${config}</div>
<div>${enabled}</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.length > 0
? extras.map( ? extras.map(
@@ -287,7 +272,6 @@ export function renderAgentCron(params: {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
onRefresh: () => void; onRefresh: () => void;
onRunNow: (jobId: string) => void;
}) { }) {
const jobs = params.jobs.filter((job) => job.agentId === params.agentId); const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
return html` return html`
@@ -357,12 +341,6 @@ export function renderAgentCron(params: {
<div class="list-meta"> <div class="list-meta">
<div class="mono">${formatCronState(job)}</div> <div class="mono">${formatCronState(job)}</div>
<div class="muted">${formatCronPayload(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>
</div> </div>
`, `,

View File

@@ -301,27 +301,17 @@ export function renderAgentSkills(params: {
} }
</div> </div>
</div> </div>
<div class="row" style="gap: 8px; flex-wrap: wrap;"> <div class="row" style="gap: 8px;">
<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)}>
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}> Use All
Enable All </button>
</button> <button
<button class="btn btn--sm"
class="btn btn--sm" ?disabled=${!editable}
?disabled=${!editable} @click=${() => params.onDisableAll(params.agentId)}
@click=${() => params.onDisableAll(params.agentId)} >
> Disable All
Disable All </button>
</button>
<button
class="btn btn--sm"
?disabled=${!editable || !usingAllowlist}
@click=${() => params.onClear(params.agentId)}
title="Remove per-agent allowlist and use all skills"
>
Reset
</button>
</div>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}> <button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config Reload Config
</button> </button>

View File

@@ -189,14 +189,6 @@ export function agentBadgeText(agentId: string, defaultId: string | null) {
return defaultId && agentId === defaultId ? "default" : 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) { export function formatBytes(bytes?: number) {
if (bytes == null || !Number.isFinite(bytes)) { if (bytes == null || !Number.isFinite(bytes)) {
return "-"; return "-";

View File

@@ -8,7 +8,6 @@ import type {
CronStatus, CronStatus,
SkillStatusReport, SkillStatusReport,
} from "../types.ts"; } from "../types.ts";
import { renderAgentOverview } from "./agents-panels-overview.ts";
import { import {
renderAgentFiles, renderAgentFiles,
renderAgentChannels, renderAgentChannels,
@@ -16,70 +15,54 @@ import {
} from "./agents-panels-status-files.ts"; } from "./agents-panels-status-files.ts";
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
import { import {
agentAvatarHue,
agentBadgeText, agentBadgeText,
buildAgentContext, buildAgentContext,
buildModelOptions,
normalizeAgentLabel, normalizeAgentLabel,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentEmoji, resolveAgentEmoji,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts"; } from "./agents-utils.ts";
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; 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 = { export type AgentsProps = {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
selectedAgentId: string | null; selectedAgentId: string | null;
activePanel: AgentsPanel; activePanel: AgentsPanel;
config: ConfigState; configForm: Record<string, unknown> | null;
channels: ChannelsState; configLoading: boolean;
cron: CronState; configSaving: boolean;
agentFiles: AgentFilesState; 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; agentIdentityLoading: boolean;
agentIdentityError: string | null; agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>; agentIdentityById: Record<string, AgentIdentityResult>;
agentSkills: AgentSkillsState; agentSkillsLoading: boolean;
sidebarFilter: string; agentSkillsReport: SkillStatusReport | null;
onSidebarFilterChange: (value: string) => void; agentSkillsError: string | null;
agentSkillsAgentId: string | null;
skillsFilter: string;
onRefresh: () => void; onRefresh: () => void;
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void; onSelectPanel: (panel: AgentsPanel) => void;
@@ -96,13 +79,20 @@ export type AgentsProps = {
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
onChannelsRefresh: () => void; onChannelsRefresh: () => void;
onCronRefresh: () => void; onCronRefresh: () => void;
onCronRunNow: (jobId: string) => void;
onSkillsFilterChange: (next: string) => void; onSkillsFilterChange: (next: string) => void;
onSkillsRefresh: () => void; onSkillsRefresh: () => void;
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
onAgentSkillsClear: (agentId: string) => void; onAgentSkillsClear: (agentId: string) => void;
onAgentSkillsDisableAll: (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) { export function renderAgents(props: AgentsProps) {
@@ -113,27 +103,6 @@ export function renderAgents(props: AgentsProps) {
? (agents.find((agent) => agent.id === selectedId) ?? null) ? (agents.find((agent) => agent.id === selectedId) ?? null)
: 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` return html`
<div class="agents-layout"> <div class="agents-layout">
<section class="card agents-sidebar"> <section class="card agents-sidebar">
@@ -146,21 +115,6 @@ export function renderAgents(props: AgentsProps) {
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </button>
</div> </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 props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ? 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;"> <div class="agent-list" style="margin-top: 12px;">
${ ${
filteredAgents.length === 0 agents.length === 0
? html` ? 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 badge = agentBadgeText(agent.id, defaultId);
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null); const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
const hue = agentAvatarHue(agent.id);
return html` return html`
<button <button
type="button" type="button"
class="agent-row ${selectedId === agent.id ? "active" : ""}" class="agent-row ${selectedId === agent.id ? "active" : ""}"
@click=${() => props.onSelectAgent(agent.id)} @click=${() => props.onSelectAgent(agent.id)}
> >
<div class="agent-avatar" style="--agent-hue: ${hue}"> <div class="agent-avatar">${emoji || normalizeAgentLabel(agent).slice(0, 1)}</div>
${emoji || normalizeAgentLabel(agent).slice(0, 1)}
</div>
<div class="agent-info"> <div class="agent-info">
<div class="agent-title">${normalizeAgentLabel(agent)}</div> <div class="agent-title">${normalizeAgentLabel(agent)}</div>
<div class="agent-sub mono">${agent.id}</div> <div class="agent-sub mono">${agent.id}</div>
@@ -210,27 +161,25 @@ export function renderAgents(props: AgentsProps) {
selectedAgent, selectedAgent,
defaultId, defaultId,
props.agentIdentityById[selectedAgent.id] ?? null, 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" props.activePanel === "overview"
? renderAgentOverview({ ? renderAgentOverview({
agent: selectedAgent, agent: selectedAgent,
defaultId, defaultId,
configForm: props.config.form, configForm: props.configForm,
agentFilesList: props.agentFiles.list, agentFilesList: props.agentFilesList,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError, agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading, agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading, configLoading: props.configLoading,
configSaving: props.config.saving, configSaving: props.configSaving,
configDirty: props.config.dirty, configDirty: props.configDirty,
onConfigReload: props.onConfigReload, onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave, onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange, onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange, onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
}) })
: nothing : nothing
} }
@@ -238,13 +187,13 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "files" props.activePanel === "files"
? renderAgentFiles({ ? renderAgentFiles({
agentId: selectedAgent.id, agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list, agentFilesList: props.agentFilesList,
agentFilesLoading: props.agentFiles.loading, agentFilesLoading: props.agentFilesLoading,
agentFilesError: props.agentFiles.error, agentFilesError: props.agentFilesError,
agentFileActive: props.agentFiles.active, agentFileActive: props.agentFileActive,
agentFileContents: props.agentFiles.contents, agentFileContents: props.agentFileContents,
agentFileDrafts: props.agentFiles.drafts, agentFileDrafts: props.agentFileDrafts,
agentFileSaving: props.agentFiles.saving, agentFileSaving: props.agentFileSaving,
onLoadFiles: props.onLoadFiles, onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile, onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange, onFileDraftChange: props.onFileDraftChange,
@@ -257,10 +206,10 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "tools" props.activePanel === "tools"
? renderAgentTools({ ? renderAgentTools({
agentId: selectedAgent.id, agentId: selectedAgent.id,
configForm: props.config.form, configForm: props.configForm,
configLoading: props.config.loading, configLoading: props.configLoading,
configSaving: props.config.saving, configSaving: props.configSaving,
configDirty: props.config.dirty, configDirty: props.configDirty,
onProfileChange: props.onToolsProfileChange, onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange, onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload, onConfigReload: props.onConfigReload,
@@ -272,15 +221,15 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "skills" props.activePanel === "skills"
? renderAgentSkills({ ? renderAgentSkills({
agentId: selectedAgent.id, agentId: selectedAgent.id,
report: props.agentSkills.report, report: props.agentSkillsReport,
loading: props.agentSkills.loading, loading: props.agentSkillsLoading,
error: props.agentSkills.error, error: props.agentSkillsError,
activeAgentId: props.agentSkills.agentId, activeAgentId: props.agentSkillsAgentId,
configForm: props.config.form, configForm: props.configForm,
configLoading: props.config.loading, configLoading: props.configLoading,
configSaving: props.config.saving, configSaving: props.configSaving,
configDirty: props.config.dirty, configDirty: props.configDirty,
filter: props.agentSkills.filter, filter: props.skillsFilter,
onFilterChange: props.onSkillsFilterChange, onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh, onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle, onToggle: props.onAgentSkillToggle,
@@ -296,16 +245,16 @@ export function renderAgents(props: AgentsProps) {
? renderAgentChannels({ ? renderAgentChannels({
context: buildAgentContext( context: buildAgentContext(
selectedAgent, selectedAgent,
props.config.form, props.configForm,
props.agentFiles.list, props.agentFilesList,
defaultId, defaultId,
props.agentIdentityById[selectedAgent.id] ?? null, props.agentIdentityById[selectedAgent.id] ?? null,
), ),
configForm: props.config.form, configForm: props.configForm,
snapshot: props.channels.snapshot, snapshot: props.channelsSnapshot,
loading: props.channels.loading, loading: props.channelsLoading,
error: props.channels.error, error: props.channelsError,
lastSuccess: props.channels.lastSuccess, lastSuccess: props.channelsLastSuccess,
onRefresh: props.onChannelsRefresh, onRefresh: props.onChannelsRefresh,
}) })
: nothing : nothing
@@ -315,18 +264,17 @@ export function renderAgents(props: AgentsProps) {
? renderAgentCron({ ? renderAgentCron({
context: buildAgentContext( context: buildAgentContext(
selectedAgent, selectedAgent,
props.config.form, props.configForm,
props.agentFiles.list, props.agentFilesList,
defaultId, defaultId,
props.agentIdentityById[selectedAgent.id] ?? null, props.agentIdentityById[selectedAgent.id] ?? null,
), ),
agentId: selectedAgent.id, agentId: selectedAgent.id,
jobs: props.cron.jobs, jobs: props.cronJobs,
status: props.cron.status, status: props.cronStatus,
loading: props.cron.loading, loading: props.cronLoading,
error: props.cron.error, error: props.cronError,
onRefresh: props.onCronRefresh, onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
}) })
: nothing : nothing
} }
@@ -337,32 +285,19 @@ export function renderAgents(props: AgentsProps) {
`; `;
} }
let actionsMenuOpen = false;
function renderAgentHeader( function renderAgentHeader(
agent: AgentsListResult["agents"][number], agent: AgentsListResult["agents"][number],
defaultId: string | null, defaultId: string | null,
agentIdentity: AgentIdentityResult | null, agentIdentity: AgentIdentityResult | null,
onSetDefault: (agentId: string) => void,
) { ) {
const badge = agentBadgeText(agent.id, defaultId); const badge = agentBadgeText(agent.id, defaultId);
const displayName = normalizeAgentLabel(agent); const displayName = normalizeAgentLabel(agent);
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing."; const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
const emoji = resolveAgentEmoji(agent, agentIdentity); 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` return html`
<section class="card agent-header"> <section class="card agent-header">
<div class="agent-header-main"> <div class="agent-header-main">
<div class="agent-avatar agent-avatar--lg" style="--agent-hue: ${hue}"> <div class="agent-avatar agent-avatar--lg">${emoji || displayName.slice(0, 1)}</div>
${emoji || displayName.slice(0, 1)}
</div>
<div> <div>
<div class="card-title">${displayName}</div> <div class="card-title">${displayName}</div>
<div class="card-sub">${subtitle}</div> <div class="card-sub">${subtitle}</div>
@@ -370,47 +305,13 @@ function renderAgentHeader(
</div> </div>
<div class="agent-header-meta"> <div class="agent-header-meta">
<div class="mono">${agent.id}</div> <div class="mono">${agent.id}</div>
<div class="row" style="gap: 8px; align-items: center;"> ${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
${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>
</div> </div>
</section> </section>
`; `;
} }
function renderAgentTabs( function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) {
active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void,
counts: Record<string, number | null>,
) {
const tabs: Array<{ id: AgentsPanel; label: string }> = [ const tabs: Array<{ id: AgentsPanel; label: string }> = [
{ id: "overview", label: "Overview" }, { id: "overview", label: "Overview" },
{ id: "files", label: "Files" }, { id: "files", label: "Files" },
@@ -428,10 +329,161 @@ function renderAgentTabs(
type="button" type="button"
@click=${() => onSelect(tab.id)} @click=${() => onSelect(tab.id)}
> >
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing} ${tab.label}
</button> </button>
`, `,
)} )}
</div> </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} @click=${callbacks.onSave}
?disabled=${state.saving || !isDirty} ?disabled=${state.saving || !isDirty}
> >
${state.saving ? "Saving..." : "Save"} ${state.saving ? "Saving..." : "Save & Publish"}
</button> </button>
<button <button

View File

@@ -45,9 +45,6 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
onSend: () => undefined, onSend: () => undefined,
onQueueRemove: () => undefined, onQueueRemove: () => undefined,
onNewSession: () => undefined, onNewSession: () => undefined,
agentsList: null,
currentAgentId: "main",
onAgentChange: () => undefined,
...overrides, ...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( function normalizeUnion(
schema: JsonSchema, schema: JsonSchema,
path: Array<string | number>, path: Array<string | number>,
): ConfigSchemaAnalysis | null { ): ConfigSchemaAnalysis | null {
if (schema.allOf) { if (schema.allOf) {
return mergeAllOf(schema, path); return null;
} }
const union = schema.anyOf ?? schema.oneOf; const union = schema.anyOf ?? schema.oneOf;
if (!union) { 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); const res = normalizeSchemaNode(remaining[0], path);
if (res.schema) { if (res.schema) {
res.schema.nullable = nullable || res.schema.nullable; res.schema.nullable = nullable || res.schema.nullable;
@@ -224,41 +189,6 @@ function normalizeUnion(
return res; 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"]); const primitiveTypes = new Set(["string", "number", "integer", "boolean"]);
if ( if (
remaining.length > 0 && remaining.length > 0 &&
@@ -274,9 +204,5 @@ function normalizeUnion(
}; };
} }
// Fallback: pass the schema through and let the renderer show a JSON textarea return null;
return {
schema: { ...schema, nullable },
unsupportedPaths: [],
};
} }

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 // SVG Icons as template literals
const icons = { const icons = {
chevronDown: html` chevronDown: html`
@@ -151,7 +113,10 @@ export function renderNode(params: {
const key = pathKey(path); const key = pathKey(path);
if (unsupported.has(key)) { 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 // Handle anyOf/oneOf unions
@@ -317,8 +282,13 @@ export function renderNode(params: {
return renderTextInput({ ...params, inputType: "text" }); return renderTextInput({ ...params, inputType: "text" });
} }
// Fallback — render a JSON textarea for types the form renderer doesn't know about // Fallback
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 type: ${type}. Use Raw mode.</div>
</div>
`;
} }
function renderTextInput(params: { function renderTextInput(params: {

View File

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

View File

@@ -1,5 +1,4 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts"; import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts"; import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
@@ -23,7 +22,6 @@ export type ConfigProps = {
searchQuery: string; searchQuery: string;
activeSection: string | null; activeSection: string | null;
activeSubsection: string | null; activeSubsection: string | null;
streamMode: boolean;
onRawChange: (next: string) => void; onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void; onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => 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) + "..."; 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) { export function renderConfig(props: ConfigProps) {
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid"; const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema); const analysis = analyzeConfigSchema(props.schema);
@@ -689,32 +649,6 @@ export function renderConfig(props: ConfigProps) {
: nothing : nothing
} }
</div> </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> </div>
` `
: nothing : nothing
@@ -748,7 +682,7 @@ export function renderConfig(props: ConfigProps) {
} }
<!-- Form content --> <!-- Form content -->
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}"> <div class="config-content">
${ ${
props.formMode === "form" props.formMode === "form"
? html` ? html`
@@ -782,43 +716,16 @@ export function renderConfig(props: ConfigProps) {
: nothing : nothing
} }
` `
: (() => { : html`
const sensitiveCount = countSensitiveValues(props.formValue); <label class="field config-raw-field">
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed); <span>Raw JSON5</span>
return html` <textarea
<label class="field config-raw-field"> .value=${props.raw}
<span style="display:flex;align-items:center;gap:8px;"> @input=${(e: Event) =>
Raw JSON5 props.onRawChange((e.target as HTMLTextAreaElement).value)}
${ ></textarea>
sensitiveCount > 0 </label>
? html` `
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
<button
class="btn btn--icon ${blurred ? "" : "active"}"
style="width:28px;height:28px;padding:0;"
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
rawRevealed = !rawRevealed;
props.onRawChange(props.raw);
}}
>
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
</span>
<textarea
class="${blurred ? "config-raw-redacted" : ""}"
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`;
})()
} }
</div> </div>

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { icons } from "../icons.ts"; import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts";
import { formatPresenceAge } from "../presenter.ts";
import type { PresenceEntry } from "../types.ts"; import type { PresenceEntry } from "../types.ts";
export type InstancesProps = { export type InstancesProps = {
@@ -8,15 +7,10 @@ export type InstancesProps = {
entries: PresenceEntry[]; entries: PresenceEntry[];
lastError: string | null; lastError: string | null;
statusMessage: string | null; statusMessage: string | null;
streamMode: boolean;
onRefresh: () => void; onRefresh: () => void;
}; };
let hostsRevealed = false;
export function renderInstances(props: InstancesProps) { export function renderInstances(props: InstancesProps) {
const masked = props.streamMode || !hostsRevealed;
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <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-title">Connected Instances</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div> <div class="card-sub">Presence beacons from the gateway and clients.</div>
</div> </div>
<div class="row" style="gap: 8px;"> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
<button ${props.loading ? "Loading…" : "Refresh"}
class="btn btn--icon ${masked ? "" : "active"}" </button>
@click=${() => {
hostsRevealed = !hostsRevealed;
props.onRefresh();
}}
title=${masked ? "Show hosts and IPs" : "Hide hosts and IPs"}
aria-label="Toggle host visibility"
aria-pressed=${!masked}
style="width: 36px; height: 36px;"
>
${masked ? icons.eyeOff : icons.eye}
</button>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div> </div>
${ ${
props.lastError props.lastError
@@ -63,18 +42,16 @@ export function renderInstances(props: InstancesProps) {
? html` ? html`
<div class="muted">No instances reported yet.</div> <div class="muted">No instances reported yet.</div>
` `
: props.entries.map((entry) => renderEntry(entry, masked)) : props.entries.map((entry) => renderEntry(entry))
} }
</div> </div>
</section> </section>
`; `;
} }
function renderEntry(entry: PresenceEntry, masked: boolean) { function renderEntry(entry: PresenceEntry) {
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
const mode = entry.mode ?? "unknown"; 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 roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel = const scopesLabel =
@@ -86,12 +63,8 @@ function renderEntry(entry: PresenceEntry, masked: boolean) {
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title"> <div class="list-title">${entry.host ?? "unknown host"}</div>
<span class="${masked ? "redacted" : ""}">${host}</span> <div class="list-sub">${formatPresenceSummary(entry)}</div>
</div>
<div class="list-sub">
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
</div>
<div class="chip-row"> <div class="chip-row">
<span class="chip">${mode}</span> <span class="chip">${mode}</span>
${roles.map((role) => html`<span class="chip">${role}</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 { t, i18n, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.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 { 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 { shouldShowPairingHint } from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = { export type OverviewProps = {
connected: boolean; connected: boolean;
@@ -30,24 +17,11 @@ export type OverviewProps = {
cronEnabled: boolean | null; cronEnabled: boolean | null;
cronNext: number | null; cronNext: number | null;
lastChannelsRefresh: 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; onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void; onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
onConnect: () => void; onConnect: () => void;
onRefresh: () => void; onRefresh: () => void;
onNavigate: (tab: string) => void;
onRefreshLogs: () => void;
onToggleStreamMode: () => void;
}; };
export function renderOverview(props: OverviewProps) { export function renderOverview(props: OverviewProps) {
@@ -60,7 +34,7 @@ export function renderOverview(props: OverviewProps) {
| undefined; | undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs 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"); : t("common.na");
const authMode = snapshot?.authMode; const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy"; const isTrustedProxy = authMode === "trusted-proxy";
@@ -190,7 +164,7 @@ export function renderOverview(props: OverviewProps) {
<div class="card"> <div class="card">
<div class="card-title">${t("overview.access.title")}</div> <div class="card-title">${t("overview.access.title")}</div>
<div class="card-sub">${t("overview.access.subtitle")}</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"> <label class="field">
<span>${t("overview.access.wsUrl")}</span> <span>${t("overview.access.wsUrl")}</span>
<input <input
@@ -209,8 +183,6 @@ export function renderOverview(props: OverviewProps) {
<label class="field"> <label class="field">
<span>${t("overview.access.token")}</span> <span>${t("overview.access.token")}</span>
<input <input
type="password"
autocomplete="off"
.value=${props.settings.token} .value=${props.settings.token}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; 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") isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span> }</span>
</div> </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>
<div class="card"> <div class="card">
@@ -341,43 +283,45 @@ export function renderOverview(props: OverviewProps) {
</div> </div>
</section> </section>
${ <section class="grid grid-cols-3" style="margin-top: 18px;">
props.streamMode <div class="card stat-card">
? html`<div class="callout ov-stream-banner" style="margin-top: 18px;"> <div class="stat-label">${t("overview.stats.instances")}</div>
<span class="nav-item__icon">${icons.radio}</span> <div class="stat-value">${props.presenceCount}</div>
${t("overview.streamMode.active")} <div class="muted">${t("overview.stats.instancesHint")}</div>
<button class="btn btn--sm" style="margin-left: auto;" @click=${() => props.onToggleStreamMode()}> </div>
${t("overview.streamMode.disable")} <div class="card stat-card">
</button> <div class="stat-label">${t("overview.stats.sessions")}</div>
</div>` <div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
: nothing <div class="muted">${t("overview.stats.sessionsHint")}</div>
} </div>
<div class="card stat-card">
${renderOverviewCards({ <div class="stat-label">${t("overview.stats.cron")}</div>
usageResult: props.usageResult, <div class="stat-value">
sessionsResult: props.sessionsResult, ${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
skillsReport: props.skillsReport, </div>
cronJobs: props.cronJobs, <div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
cronStatus: props.cronStatus, </div>
presenceCount: props.presenceCount, </section>
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="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; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 10px; padding: 4px 10px;
background: color-mix(in srgb, var(--accent) 10%, transparent); background: rgba(255, 77, 77, 0.1);
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
color: var(--accent); color: #ff4d4d;
} }
.usage-refresh-indicator::before { .usage-refresh-indicator::before {
content: ""; content: "";
width: 10px; width: 10px;
height: 10px; height: 10px;
border: 2px solid var(--accent); border: 2px solid #ff4d4d;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
animation: usage-spin 0.6s linear infinite; animation: usage-spin 0.6s linear infinite;
@@ -161,36 +161,36 @@ export const usageStylesPart1 = `
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.usage-primary-btn { .usage-primary-btn {
background: var(--accent); background: #ff4d4d;
color: #fff; color: #fff;
border-color: var(--accent); border-color: #ff4d4d;
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12); box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12);
} }
.btn.usage-primary-btn { .btn.usage-primary-btn {
background: var(--accent) !important; background: #ff4d4d !important;
border-color: var(--accent) !important; border-color: #ff4d4d !important;
color: #fff !important; color: #fff !important;
} }
.usage-primary-btn:hover { .usage-primary-btn:hover {
background: var(--accent-strong); background: #e64545;
border-color: var(--accent-strong); border-color: #e64545;
} }
.btn.usage-primary-btn:hover { .btn.usage-primary-btn:hover {
background: var(--accent-strong) !important; background: #e64545 !important;
border-color: var(--accent-strong) !important; border-color: #e64545 !important;
} }
.usage-primary-btn:disabled { .usage-primary-btn:disabled {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: rgba(255, 77, 77, 0.18);
border-color: color-mix(in srgb, var(--accent) 30%, transparent); border-color: rgba(255, 77, 77, 0.3);
color: var(--accent); color: #ff4d4d;
box-shadow: none; box-shadow: none;
cursor: default; cursor: default;
opacity: 1; opacity: 1;
} }
.usage-primary-btn[disabled] { .usage-primary-btn[disabled] {
background: color-mix(in srgb, var(--accent) 18%, transparent) !important; background: rgba(255, 77, 77, 0.18) !important;
border-color: color-mix(in srgb, var(--accent) 30%, transparent) !important; border-color: rgba(255, 77, 77, 0.3) !important;
color: var(--accent) !important; color: #ff4d4d !important;
opacity: 1 !important; opacity: 1 !important;
} }
.usage-secondary-btn { .usage-secondary-btn {
@@ -533,8 +533,8 @@ export const usageStylesPart1 = `
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px;
color: var(--text); color: var(--text);
background: color-mix(in srgb, var(--accent) 8%, transparent); background: rgba(255, 77, 77, 0.08);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid rgba(255, 77, 77, 0.2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -554,14 +554,14 @@ export const usageStylesPart1 = `
.usage-hour-cell { .usage-hour-cell {
height: 28px; height: 28px;
border-radius: 6px; border-radius: 6px;
background: color-mix(in srgb, var(--accent) 10%, transparent); background: rgba(255, 77, 77, 0.1);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid rgba(255, 77, 77, 0.2);
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
} }
.usage-hour-cell.selected { .usage-hour-cell.selected {
border-color: color-mix(in srgb, var(--accent) 80%, transparent); border-color: rgba(255, 77, 77, 0.8);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2);
} }
.usage-hour-labels { .usage-hour-labels {
display: grid; display: grid;
@@ -584,8 +584,8 @@ export const usageStylesPart1 = `
width: 14px; width: 14px;
height: 10px; height: 10px;
border-radius: 4px; border-radius: 4px;
background: color-mix(in srgb, var(--accent) 15%, transparent); background: rgba(255, 77, 77, 0.15);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid rgba(255, 77, 77, 0.2);
} }
.usage-calendar-labels { .usage-calendar-labels {
display: grid; display: grid;
@@ -603,8 +603,8 @@ export const usageStylesPart1 = `
.usage-calendar-cell { .usage-calendar-cell {
height: 18px; height: 18px;
border-radius: 4px; border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid rgba(255, 77, 77, 0.2);
background: color-mix(in srgb, var(--accent) 8%, transparent); background: rgba(255, 77, 77, 0.08);
} }
.usage-calendar-cell.empty { .usage-calendar-cell.empty {
background: transparent; background: transparent;

View File

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

View File

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

View File

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