mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
UI: gateway dashboard with glassmorphism theme system
Add a full-featured gateway dashboard UI built on Lit web components. Shell & plumbing: - App shell with router, controllers, and dependency wiring - Login gate, i18n keys, and base layout scaffolding Styles & theming: - Base styles, chat styles, and responsive layout CSS - 6-theme glassmorphism system (Obsidian, Aurora, Solar, etc.) - Glass card, glass panel, and glass input components - Favicon logo in expanded sidebar header Views & features: - Overview with attention cards, event log, quick actions, and log tail - Chat view with markdown rendering, tool-call collapse, and delete support - Command palette with fuzzy search - Agent overview with config display, slash commands, and sidebar filtering - Session list navigation and agent selector Privacy & polish: - Redact toggle with stream-mode default - Blur host/IP in Connected Instances with reveal toggle - Sensitive config value masking with count badge - Card accent borders, hover lift effects, and responsive grid
This commit is contained in:
@@ -8,6 +8,18 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<script>
|
||||
(function () {
|
||||
var VALID = ["dark", "light", "openknot", "fieldmanual", "openai", "clawdash"];
|
||||
try {
|
||||
var s = JSON.parse(localStorage.getItem("openclaw.control.settings.v1") || "{}");
|
||||
var t = s && s.theme;
|
||||
if (t && VALID.indexOf(t) !== -1) {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<openclaw-app></openclaw-app>
|
||||
|
||||
@@ -12,6 +12,7 @@ export const en: TranslationMap = {
|
||||
na: "n/a",
|
||||
docs: "Docs",
|
||||
resources: "Resources",
|
||||
search: "Search",
|
||||
},
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
@@ -99,6 +100,47 @@ export const en: TranslationMap = {
|
||||
hint: "This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open {url} on the gateway host.",
|
||||
stayHttp: "If you must stay on HTTP, set {config} (token-only).",
|
||||
},
|
||||
connection: {
|
||||
title: "How to connect",
|
||||
step1: "Start the gateway on your host machine:",
|
||||
step2: "Get a tokenized dashboard URL:",
|
||||
step3: "Paste the WebSocket URL and token above, or open the tokenized URL directly.",
|
||||
step4: "Or generate a reusable token:",
|
||||
docsHint: "For remote access, Tailscale Serve is recommended. ",
|
||||
docsLink: "Read the docs →",
|
||||
},
|
||||
cards: {
|
||||
cost: "Cost",
|
||||
skills: "Skills",
|
||||
recentSessions: "Recent Sessions",
|
||||
},
|
||||
attention: {
|
||||
title: "Attention",
|
||||
},
|
||||
eventLog: {
|
||||
title: "Event Log",
|
||||
},
|
||||
logTail: {
|
||||
title: "Gateway Logs",
|
||||
},
|
||||
quickActions: {
|
||||
newSession: "New Session",
|
||||
automation: "Automation",
|
||||
refreshAll: "Refresh All",
|
||||
terminal: "Terminal",
|
||||
},
|
||||
streamMode: {
|
||||
active: "Stream mode — values redacted",
|
||||
disable: "Disable",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "Type a command…",
|
||||
noResults: "No results",
|
||||
},
|
||||
},
|
||||
login: {
|
||||
subtitle: "Gateway Dashboard",
|
||||
passwordPlaceholder: "optional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const pt_BR: TranslationMap = {
|
||||
na: "n/a",
|
||||
docs: "Docs",
|
||||
resources: "Recursos",
|
||||
search: "Pesquisar",
|
||||
},
|
||||
nav: {
|
||||
chat: "Chat",
|
||||
@@ -101,6 +102,47 @@ export const pt_BR: TranslationMap = {
|
||||
hint: "Esta página é HTTP, então o navegador bloqueia a identidade do dispositivo. Use HTTPS (Tailscale Serve) ou abra {url} no host do gateway.",
|
||||
stayHttp: "Se você precisar permanecer em HTTP, defina {config} (apenas token).",
|
||||
},
|
||||
connection: {
|
||||
title: "Como conectar",
|
||||
step1: "Inicie o gateway na sua máquina host:",
|
||||
step2: "Obtenha uma URL do painel com token:",
|
||||
step3: "Cole a URL do WebSocket e o token acima, ou abra a URL com token diretamente.",
|
||||
step4: "Ou gere um token reutilizável:",
|
||||
docsHint: "Para acesso remoto, recomendamos o Tailscale Serve. ",
|
||||
docsLink: "Leia a documentação →",
|
||||
},
|
||||
cards: {
|
||||
cost: "Custo",
|
||||
skills: "Habilidades",
|
||||
recentSessions: "Sessões Recentes",
|
||||
},
|
||||
attention: {
|
||||
title: "Atenção",
|
||||
},
|
||||
eventLog: {
|
||||
title: "Log de Eventos",
|
||||
},
|
||||
logTail: {
|
||||
title: "Logs do Gateway",
|
||||
},
|
||||
quickActions: {
|
||||
newSession: "Nova Sessão",
|
||||
automation: "Automação",
|
||||
refreshAll: "Atualizar Tudo",
|
||||
terminal: "Terminal",
|
||||
},
|
||||
streamMode: {
|
||||
active: "Modo stream — valores ocultos",
|
||||
disable: "Desativar",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "Digite um comando…",
|
||||
noResults: "Sem resultados",
|
||||
},
|
||||
},
|
||||
login: {
|
||||
subtitle: "Painel do Gateway",
|
||||
passwordPlaceholder: "opcional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Desconectado do gateway.",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const zh_CN: TranslationMap = {
|
||||
na: "不适用",
|
||||
docs: "文档",
|
||||
resources: "资源",
|
||||
search: "搜索",
|
||||
},
|
||||
nav: {
|
||||
chat: "聊天",
|
||||
@@ -98,6 +99,47 @@ export const zh_CN: TranslationMap = {
|
||||
hint: "此页面为 HTTP,因此浏览器阻止设备标识。请使用 HTTPS (Tailscale Serve) 或在网关主机上打开 {url}。",
|
||||
stayHttp: "如果您必须保持 HTTP,请设置 {config} (仅限令牌)。",
|
||||
},
|
||||
connection: {
|
||||
title: "如何连接",
|
||||
step1: "在主机上启动网关:",
|
||||
step2: "获取带令牌的仪表盘 URL:",
|
||||
step3: "将 WebSocket URL 和令牌粘贴到上方,或直接打开带令牌的 URL。",
|
||||
step4: "或生成可重复使用的令牌:",
|
||||
docsHint: "如需远程访问,建议使用 Tailscale Serve。",
|
||||
docsLink: "查看文档 →",
|
||||
},
|
||||
cards: {
|
||||
cost: "费用",
|
||||
skills: "技能",
|
||||
recentSessions: "最近会话",
|
||||
},
|
||||
attention: {
|
||||
title: "注意事项",
|
||||
},
|
||||
eventLog: {
|
||||
title: "事件日志",
|
||||
},
|
||||
logTail: {
|
||||
title: "网关日志",
|
||||
},
|
||||
quickActions: {
|
||||
newSession: "新建会话",
|
||||
automation: "自动化",
|
||||
refreshAll: "全部刷新",
|
||||
terminal: "终端",
|
||||
},
|
||||
streamMode: {
|
||||
active: "流模式 — 数据已隐藏",
|
||||
disable: "禁用",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "输入命令…",
|
||||
noResults: "无结果",
|
||||
},
|
||||
},
|
||||
login: {
|
||||
subtitle: "网关仪表盘",
|
||||
passwordPlaceholder: "可选",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已断开与网关的连接。",
|
||||
|
||||
@@ -12,6 +12,7 @@ export const zh_TW: TranslationMap = {
|
||||
na: "不適用",
|
||||
docs: "文檔",
|
||||
resources: "資源",
|
||||
search: "搜尋",
|
||||
},
|
||||
nav: {
|
||||
chat: "聊天",
|
||||
@@ -98,6 +99,47 @@ export const zh_TW: TranslationMap = {
|
||||
hint: "此頁面為 HTTP,因此瀏覽器阻止設備標識。請使用 HTTPS (Tailscale Serve) 或在網關主機上打開 {url}。",
|
||||
stayHttp: "如果您必須保持 HTTP,請設置 {config} (僅限令牌)。",
|
||||
},
|
||||
connection: {
|
||||
title: "如何連接",
|
||||
step1: "在主機上啟動閘道:",
|
||||
step2: "取得帶令牌的儀表板 URL:",
|
||||
step3: "將 WebSocket URL 和令牌貼到上方,或直接開啟帶令牌的 URL。",
|
||||
step4: "或產生可重複使用的令牌:",
|
||||
docsHint: "如需遠端存取,建議使用 Tailscale Serve。",
|
||||
docsLink: "查看文件 →",
|
||||
},
|
||||
cards: {
|
||||
cost: "費用",
|
||||
skills: "技能",
|
||||
recentSessions: "最近會話",
|
||||
},
|
||||
attention: {
|
||||
title: "注意事項",
|
||||
},
|
||||
eventLog: {
|
||||
title: "事件日誌",
|
||||
},
|
||||
logTail: {
|
||||
title: "閘道日誌",
|
||||
},
|
||||
quickActions: {
|
||||
newSession: "新建會話",
|
||||
automation: "自動化",
|
||||
refreshAll: "全部刷新",
|
||||
terminal: "終端",
|
||||
},
|
||||
streamMode: {
|
||||
active: "串流模式 — 數據已隱藏",
|
||||
disable: "禁用",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "輸入指令…",
|
||||
noResults: "無結果",
|
||||
},
|
||||
},
|
||||
login: {
|
||||
subtitle: "閘道儀表板",
|
||||
passwordPlaceholder: "可選",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已斷開與網關的連接。",
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/glass.css";
|
||||
@import "./styles/config.css";
|
||||
|
||||
@@ -1,108 +1,500 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;700&family=JetBrains+Mono:wght@400;500;700&display=swap");
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════
|
||||
Theme System — 6 Glassmorphism Themes
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ─── Design Tokens (shared across all themes) ─── */
|
||||
|
||||
:root {
|
||||
/* Background - Warmer dark with depth */
|
||||
--bg: #12141a;
|
||||
--bg-accent: #14161d;
|
||||
--bg-elevated: #1a1d25;
|
||||
--bg-hover: #262a35;
|
||||
--bg-muted: #262a35;
|
||||
--icon-size-xs: 0.9rem;
|
||||
--icon-size-sm: 1.05rem;
|
||||
--icon-size-md: 1.25rem;
|
||||
--icon-size-xl: 2.4rem;
|
||||
|
||||
/* Card / Surface - More contrast between levels */
|
||||
--card: #181b22;
|
||||
--card-foreground: #f4f4f5;
|
||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||
--popover: #181b22;
|
||||
--popover-foreground: #f4f4f5;
|
||||
--font-inter: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-serif: "Playfair Display", Georgia, "Times New Roman", serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
|
||||
/* Panel */
|
||||
--panel: #12141a;
|
||||
--panel-strong: #1a1d25;
|
||||
--panel-hover: #262a35;
|
||||
--chrome: rgba(18, 20, 26, 0.95);
|
||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||
|
||||
/* Text - Slightly warmer */
|
||||
--text: #e4e4e7;
|
||||
--text-strong: #fafafa;
|
||||
--chat-text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
/* Border - Subtle but defined */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
--ring: #ff5c5c;
|
||||
|
||||
/* Accent - Punchy signature red */
|
||||
--accent: #ff5c5c;
|
||||
--accent-hover: #ff7070;
|
||||
--accent-muted: #ff5c5c;
|
||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||
--accent-foreground: #fafafa;
|
||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||
--primary: #ff5c5c;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary - Teal accent for variety */
|
||||
--secondary: #1e2028;
|
||||
--secondary-foreground: #f4f4f5;
|
||||
--accent-2: #14b8a6;
|
||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
||||
|
||||
/* Semantic - More saturated */
|
||||
--ok: #22c55e;
|
||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #f59e0b;
|
||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
||||
--danger: #ef4444;
|
||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Focus - With glow */
|
||||
--focus: rgba(255, 92, 92, 0.25);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
||||
|
||||
/* Grid */
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* Theme transition */
|
||||
--theme-switch-x: 50%;
|
||||
--theme-switch-y: 50%;
|
||||
}
|
||||
|
||||
/* Typography - Space Grotesk for personality */
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display:
|
||||
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--clay-duration-fast: 0ms;
|
||||
--clay-duration-normal: 0ms;
|
||||
--clay-duration-slow: 0ms;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
* {
|
||||
animation-duration: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Radii - Slightly larger for friendlier feel */
|
||||
/* ─── Theme: dark (Home) — Deep-sea Operations Console ─── */
|
||||
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #040810;
|
||||
--vscode-sidebar: #06090f;
|
||||
--vscode-panel: #0a0e16;
|
||||
--vscode-panel-border: rgba(0, 212, 170, 0.08);
|
||||
--vscode-surface: #0e1420;
|
||||
--vscode-hover: #121a28;
|
||||
--vscode-contrast: #020408;
|
||||
--vscode-text: #d0d8e4;
|
||||
--vscode-muted: #6e7a8a;
|
||||
--vscode-subtle: #3a4454;
|
||||
--vscode-ghost: #0c1018;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
|
||||
--vscode-selection: #3d1418;
|
||||
--vscode-success: #00d4aa;
|
||||
--vscode-danger: #ca3a29;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #fd8e2e;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.12);
|
||||
--kn-claw-ember: #fb9231;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #09181e;
|
||||
--kn-ocean-bright: #132a36;
|
||||
--kn-ocean-mid: #0c1e28;
|
||||
--kn-ocean-dim: rgba(9, 24, 30, 0.8);
|
||||
--kn-ocean-deep: #040810;
|
||||
--kn-silver: #8a9baa;
|
||||
--kn-silver-bright: #c0cdd6;
|
||||
--kn-silver-dim: rgba(138, 155, 170, 0.12);
|
||||
--kn-bioluminescence: #00d4aa;
|
||||
--kn-warm-dark: #221016;
|
||||
--kn-void: #221016;
|
||||
|
||||
--glass-blur: 8px;
|
||||
--glass-saturate: 120%;
|
||||
--glass-bg: rgba(10, 14, 22, 0.82);
|
||||
--glass-bg-elevated: rgba(14, 20, 32, 0.88);
|
||||
--glass-border: rgba(0, 212, 170, 0.08);
|
||||
--glass-border-hover: rgba(202, 58, 41, 0.3);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 212, 170, 0.06);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 212, 170, 0.08);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #0e0c0e;
|
||||
--vscode-sidebar: #131012;
|
||||
--vscode-panel: #161214;
|
||||
--vscode-panel-border: rgba(255, 255, 255, 0.06);
|
||||
--vscode-surface: #1a1618;
|
||||
--vscode-hover: #201c1e;
|
||||
--vscode-contrast: #080608;
|
||||
--vscode-text: #d5d0cf;
|
||||
--vscode-muted: #7a7472;
|
||||
--vscode-subtle: #4a4442;
|
||||
--vscode-ghost: #1a1616;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
|
||||
--vscode-selection: #3d1418;
|
||||
--vscode-success: #00d4aa;
|
||||
--vscode-danger: #ca3a29;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #fd8e2e;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.12);
|
||||
--kn-claw-ember: #fb9231;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #0e0c0e;
|
||||
--kn-ocean-bright: #201c1e;
|
||||
--kn-ocean-mid: #161214;
|
||||
--kn-ocean-dim: rgba(14, 12, 14, 0.8);
|
||||
--kn-ocean-deep: #0e0c0e;
|
||||
--kn-silver: #8a7e72;
|
||||
--kn-silver-bright: #c0b4a8;
|
||||
--kn-silver-dim: rgba(138, 126, 114, 0.12);
|
||||
--kn-bioluminescence: #00d4aa;
|
||||
--kn-warm-dark: #1a1416;
|
||||
--kn-void: #1a1416;
|
||||
|
||||
--glass-blur: 0px;
|
||||
--glass-saturate: 100%;
|
||||
--glass-bg: rgba(22, 18, 20, 0.95);
|
||||
--glass-bg-elevated: rgba(26, 22, 24, 0.96);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-border-hover: rgba(202, 58, 41, 0.25);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: openknot — Minimalist Premium Noir ─── */
|
||||
|
||||
:root[data-theme="openknot"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #000000;
|
||||
--vscode-sidebar: #080808;
|
||||
--vscode-panel: #0c0c0c;
|
||||
--vscode-panel-border: rgba(167, 139, 250, 0.08);
|
||||
--vscode-surface: #111111;
|
||||
--vscode-hover: #181818;
|
||||
--vscode-contrast: #000000;
|
||||
--vscode-text: #e4e4e7;
|
||||
--vscode-muted: #71717a;
|
||||
--vscode-subtle: #3f3f46;
|
||||
--vscode-ghost: #18181b;
|
||||
--vscode-accent: #a78bfa;
|
||||
--vscode-accent-alpha: rgba(167, 139, 250, 0.14);
|
||||
--vscode-selection: #2e1a5e;
|
||||
--vscode-success: #a78bfa;
|
||||
--vscode-danger: #a78bfa;
|
||||
|
||||
--kn-claw: #a78bfa;
|
||||
--kn-claw-bright: #c4b5fd;
|
||||
--kn-claw-dim: rgba(167, 139, 250, 0.12);
|
||||
--kn-claw-ember: #c4b5fd;
|
||||
--kn-claw-deep: #7c3aed;
|
||||
--kn-ocean: #000000;
|
||||
--kn-ocean-bright: #1a1a1e;
|
||||
--kn-ocean-mid: #0e0e12;
|
||||
--kn-ocean-dim: rgba(0, 0, 0, 0.8);
|
||||
--kn-ocean-deep: #000000;
|
||||
--kn-silver: #71717a;
|
||||
--kn-silver-bright: #a1a1aa;
|
||||
--kn-silver-dim: rgba(113, 113, 122, 0.12);
|
||||
--kn-bioluminescence: #c4b5fd;
|
||||
--kn-warm-dark: #18181b;
|
||||
--kn-void: #18181b;
|
||||
|
||||
--glass-blur: 12px;
|
||||
--glass-saturate: 110%;
|
||||
--glass-bg: rgba(12, 12, 12, 0.85);
|
||||
--glass-bg-elevated: rgba(17, 17, 17, 0.9);
|
||||
--glass-border: rgba(167, 139, 250, 0.08);
|
||||
--glass-border-hover: rgba(167, 139, 250, 0.3);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(167, 139, 250, 0.06);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(167, 139, 250, 0.08);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: fieldmanual — Industrial Dossier ─── */
|
||||
|
||||
:root[data-theme="fieldmanual"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #0e0e0e;
|
||||
--vscode-sidebar: #121212;
|
||||
--vscode-panel: #161616;
|
||||
--vscode-panel-border: rgba(255, 255, 255, 0.1);
|
||||
--vscode-surface: #1a1a1a;
|
||||
--vscode-hover: #222222;
|
||||
--vscode-contrast: #0a0a0a;
|
||||
--vscode-text: #d4d4d4;
|
||||
--vscode-muted: #737373;
|
||||
--vscode-subtle: #404040;
|
||||
--vscode-ghost: #1a1a1a;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
|
||||
--vscode-selection: #3d1418;
|
||||
--vscode-success: #61d6ff;
|
||||
--vscode-danger: #ca3a29;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #ff6b4a;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.12);
|
||||
--kn-claw-ember: #ff6b4a;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #0e0e0e;
|
||||
--kn-ocean-bright: #222222;
|
||||
--kn-ocean-mid: #161616;
|
||||
--kn-ocean-dim: rgba(14, 14, 14, 0.8);
|
||||
--kn-ocean-deep: #0e0e0e;
|
||||
--kn-silver: #737373;
|
||||
--kn-silver-bright: #a3a3a3;
|
||||
--kn-silver-dim: rgba(115, 115, 115, 0.12);
|
||||
--kn-bioluminescence: #61d6ff;
|
||||
--kn-warm-dark: #1a1a1a;
|
||||
--kn-void: #1a1a1a;
|
||||
|
||||
--glass-blur: 0px;
|
||||
--glass-saturate: 100%;
|
||||
--glass-bg: rgba(22, 22, 22, 0.95);
|
||||
--glass-bg-elevated: rgba(26, 26, 26, 0.96);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
--glass-border-hover: rgba(202, 58, 41, 0.35);
|
||||
--glass-highlight: none;
|
||||
--glass-shadow-sm: none;
|
||||
--glass-shadow-md: none;
|
||||
--glass-shadow-lg: none;
|
||||
|
||||
--radius-xs: 0px;
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-full: 0px;
|
||||
}
|
||||
|
||||
/* ─── Theme: openai — Crimson Glassmorphic ─── */
|
||||
|
||||
:root[data-theme="openai"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #0c0606;
|
||||
--vscode-sidebar: #100808;
|
||||
--vscode-panel: #140a0a;
|
||||
--vscode-panel-border: rgba(202, 58, 41, 0.12);
|
||||
--vscode-surface: #1a0e0e;
|
||||
--vscode-hover: #221414;
|
||||
--vscode-contrast: #060202;
|
||||
--vscode-text: #e8d8d4;
|
||||
--vscode-muted: #8a6a64;
|
||||
--vscode-subtle: #4a3430;
|
||||
--vscode-ghost: #1a0e0e;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.18);
|
||||
--vscode-selection: #7d261c;
|
||||
--vscode-success: #fd8e2e;
|
||||
--vscode-danger: #ca3a29;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #ff4e41;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.15);
|
||||
--kn-claw-ember: #fd8e2e;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #0c0606;
|
||||
--kn-ocean-bright: #221414;
|
||||
--kn-ocean-mid: #140a0a;
|
||||
--kn-ocean-dim: rgba(12, 6, 6, 0.8);
|
||||
--kn-ocean-deep: #0c0606;
|
||||
--kn-silver: #8a6a64;
|
||||
--kn-silver-bright: #c0a49c;
|
||||
--kn-silver-dim: rgba(138, 106, 100, 0.12);
|
||||
--kn-bioluminescence: #fd8e2e;
|
||||
--kn-warm-dark: #221016;
|
||||
--kn-void: #221016;
|
||||
|
||||
--glass-blur: 14px;
|
||||
--glass-saturate: 130%;
|
||||
--glass-bg: rgba(20, 10, 10, 0.78);
|
||||
--glass-bg-elevated: rgba(26, 14, 14, 0.85);
|
||||
--glass-border: rgba(202, 58, 41, 0.12);
|
||||
--glass-border-hover: rgba(202, 58, 41, 0.4);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(202, 58, 41, 0.08);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(202, 58, 41, 0.1);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(202, 58, 41, 0.12);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: clawdash — Chrome Metallic ─── */
|
||||
|
||||
:root[data-theme="clawdash"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #050507;
|
||||
--vscode-sidebar: #08080c;
|
||||
--vscode-panel: #0c0c10;
|
||||
--vscode-panel-border: rgba(192, 200, 212, 0.1);
|
||||
--vscode-surface: #101014;
|
||||
--vscode-hover: #161620;
|
||||
--vscode-contrast: #020204;
|
||||
--vscode-text: #e8ecf0;
|
||||
--vscode-muted: #8a94a4;
|
||||
--vscode-subtle: #4a5060;
|
||||
--vscode-ghost: #1a1a22;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
|
||||
--vscode-selection: #3d1418;
|
||||
--vscode-success: #00d4aa;
|
||||
--vscode-danger: #ca3a29;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #ff4e41;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.12);
|
||||
--kn-claw-ember: #fd8e2e;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #08080c;
|
||||
--kn-ocean-bright: #161620;
|
||||
--kn-ocean-mid: #0c0c10;
|
||||
--kn-ocean-dim: rgba(8, 8, 12, 0.8);
|
||||
--kn-ocean-deep: #050507;
|
||||
--kn-silver: #7a8494;
|
||||
--kn-silver-bright: #c0c8d4;
|
||||
--kn-silver-dim: rgba(192, 200, 212, 0.12);
|
||||
--kn-bioluminescence: #00d4aa;
|
||||
--kn-warm-dark: #1a1a22;
|
||||
--kn-void: #1a1a22;
|
||||
|
||||
--glass-blur: 16px;
|
||||
--glass-saturate: 150%;
|
||||
--glass-bg: rgba(12, 12, 16, 0.8);
|
||||
--glass-bg-elevated: rgba(16, 16, 20, 0.88);
|
||||
--glass-border: rgba(192, 200, 212, 0.08);
|
||||
--glass-border-hover: rgba(192, 200, 212, 0.25);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.4), 0 0 4px rgba(192, 200, 212, 0.04);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 12px rgba(192, 200, 212, 0.06);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 24px rgba(192, 200, 212, 0.08);
|
||||
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-lg: 10px;
|
||||
--radius-xl: 14px;
|
||||
--radius-full: 9999px;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
/* Transitions - Snappy but smooth */
|
||||
/* ─── 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-glow: var(--kn-claw-dim);
|
||||
--accent-soft: var(--vscode-accent-alpha);
|
||||
--primary: var(--vscode-accent);
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: var(--vscode-sidebar);
|
||||
--secondary-foreground: var(--vscode-text);
|
||||
--accent-2: var(--kn-bioluminescence);
|
||||
--accent-2-muted: var(--kn-silver);
|
||||
--accent-2-subtle: var(--kn-silver-dim);
|
||||
|
||||
/* Semantic */
|
||||
--ok: var(--vscode-success);
|
||||
--ok-muted: var(--vscode-success);
|
||||
--ok-subtle: var(--kn-silver-dim);
|
||||
--destructive: var(--vscode-danger);
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: var(--kn-claw-ember);
|
||||
--warn-muted: var(--kn-claw-ember);
|
||||
--warn-subtle: var(--kn-claw-dim);
|
||||
--danger: var(--vscode-danger);
|
||||
--danger-muted: var(--vscode-danger);
|
||||
--danger-subtle: var(--kn-claw-dim);
|
||||
--info: #3b82f6;
|
||||
--success: var(--vscode-success);
|
||||
|
||||
/* Focus */
|
||||
--focus: var(--kn-claw-dim);
|
||||
--focus-offset-color: var(--bg);
|
||||
--focus-ring-width: 2px;
|
||||
--focus-ring-offset-width: 2px;
|
||||
--focus-ring-color: var(--vscode-accent);
|
||||
--focus-ring:
|
||||
0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color),
|
||||
0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color);
|
||||
--focus-glow:
|
||||
0 0 0 var(--focus-ring-offset-width) var(--focus-offset-color),
|
||||
0 0 0 calc(var(--focus-ring-offset-width) + var(--focus-ring-width)) var(--focus-ring-color),
|
||||
0 0 18px var(--accent-glow);
|
||||
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: var(--glass-shadow-sm);
|
||||
--shadow-md: var(--glass-shadow-md);
|
||||
--shadow-lg: var(--glass-shadow-lg);
|
||||
--shadow-xl: var(--glass-shadow-lg);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
|
||||
/* Radii — aliased from foundation */
|
||||
--radius: var(--radius-md);
|
||||
|
||||
/* Timing */
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
@@ -110,88 +502,68 @@
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 350ms;
|
||||
|
||||
color-scheme: dark;
|
||||
/* Typography stacks */
|
||||
--mono:
|
||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
/* Clay compat layer (dashboard-lit components) */
|
||||
--clay-bg: var(--vscode-bg);
|
||||
--clay-bg-card: var(--vscode-panel);
|
||||
--clay-bg-elevated: var(--vscode-surface);
|
||||
--clay-bg-button: var(--vscode-hover);
|
||||
--clay-bg-interactive: var(--vscode-accent-alpha);
|
||||
--clay-bg-pressed: var(--vscode-selection);
|
||||
--clay-bg-scrim: rgba(0, 0, 0, 0.6);
|
||||
--clay-border-color: var(--glass-border);
|
||||
--clay-border-subtle: var(--vscode-panel-border);
|
||||
--clay-shadow: var(--glass-shadow-sm);
|
||||
--clay-shadow-elevated: var(--glass-shadow-md);
|
||||
--clay-shadow-pressed: var(--glass-shadow-sm);
|
||||
--clay-shadow-subtle: var(--glass-shadow-sm);
|
||||
--clay-radius-sm: var(--radius-sm);
|
||||
--clay-radius: var(--radius-md);
|
||||
--clay-radius-md: var(--radius-md);
|
||||
--clay-radius-lg: var(--radius-lg);
|
||||
--clay-radius-xl: var(--radius-xl);
|
||||
--clay-radius-pill: var(--radius-full);
|
||||
--clay-duration-fast: 150ms;
|
||||
--clay-duration-normal: 250ms;
|
||||
--clay-duration-slow: 400ms;
|
||||
--clay-easing: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Layout semantic tokens */
|
||||
--topbar-bg: var(--vscode-sidebar);
|
||||
--topbar-shadow: none;
|
||||
--topbar-border: 1px solid var(--glass-border);
|
||||
--topbar-title-color: var(--vscode-text);
|
||||
--topbar-title-weight: 600;
|
||||
--sidebar-bg: var(--vscode-sidebar);
|
||||
--sidebar-border: none;
|
||||
--sidebar-nav-inactive: var(--vscode-muted);
|
||||
--sidebar-nav-active-bg: var(--vscode-accent-alpha);
|
||||
--sidebar-nav-active-bar: 3px solid var(--vscode-accent);
|
||||
--agent-header-bg: var(--vscode-panel);
|
||||
--agent-header-border: 1px solid var(--glass-border);
|
||||
--agent-tab-active-bg: var(--vscode-accent-alpha);
|
||||
--agent-tab-hover-bg: var(--vscode-accent-alpha);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* ─── Accessibility: High Contrast ─── */
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18181b;
|
||||
|
||||
--panel: #fafafa;
|
||||
--panel-strong: #f5f5f5;
|
||||
--panel-hover: #ebebeb;
|
||||
--chrome: rgba(250, 250, 250, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||
|
||||
--text: #3f3f46;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--accent-muted: #dc2626;
|
||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||
--primary: #dc2626;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #3f3f46;
|
||||
--accent-2: #0d9488;
|
||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
||||
|
||||
--ok: #16a34a;
|
||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #fafafa;
|
||||
--warn: #d97706;
|
||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
||||
--danger: #dc2626;
|
||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--info: #2563eb;
|
||||
|
||||
--focus: rgba(220, 38, 38, 0.2);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
--grid-line: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Light shadows */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
color-scheme: light;
|
||||
@media (prefers-contrast: more) {
|
||||
:root {
|
||||
--glass-shadow-sm: 0 0 0 2px var(--vscode-text);
|
||||
--glass-shadow-md: 0 0 0 2px var(--vscode-text);
|
||||
--glass-shadow-lg: 0 0 0 2px var(--vscode-text);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* ════════════════════════════════════════════════════════
|
||||
Base Styles
|
||||
════════════════════════════════════════════════════════ */
|
||||
|
||||
html,
|
||||
body {
|
||||
@@ -200,8 +572,8 @@ body {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 400 14px/1.55 var(--font-body);
|
||||
letter-spacing: -0.02em;
|
||||
font: 400 15px/1.55 var(--font-body);
|
||||
letter-spacing: -0.01em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -289,7 +661,170 @@ select {
|
||||
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 {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -361,6 +896,15 @@ select {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chrome-shimmer {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stagger animation delays for grouped elements */
|
||||
.stagger-1 {
|
||||
animation-delay: 0ms;
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
@import "./chat/grouped.css";
|
||||
@import "./chat/tool-cards.css";
|
||||
@import "./chat/sidebar.css";
|
||||
@import "./chat/agent-chat.css";
|
||||
|
||||
1287
ui/src/styles/chat/agent-chat.css
Normal file
1287
ui/src/styles/chat/agent-chat.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -83,14 +83,15 @@
|
||||
|
||||
/* Avatar Styles */
|
||||
.chat-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-strong);
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--panel-strong) 95%, transparent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end; /* Align with last message in group */
|
||||
margin-bottom: 4px; /* Optical alignment */
|
||||
@@ -127,14 +128,15 @@ img.chat-avatar {
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid transparent;
|
||||
background: var(--card);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 97%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: none;
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
border-color 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -147,8 +149,8 @@ img.chat-avatar {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg) 94%, transparent);
|
||||
color: var(--muted);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 6px;
|
||||
@@ -159,7 +161,8 @@ img.chat-avatar {
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease-out,
|
||||
background 120ms ease-out;
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-copy-btn__icon {
|
||||
@@ -206,6 +209,7 @@ img.chat-avatar {
|
||||
|
||||
.chat-copy-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.chat-copy-btn[data-copying="1"] {
|
||||
@@ -243,29 +247,20 @@ img.chat-avatar {
|
||||
}
|
||||
}
|
||||
|
||||
/* Light mode: restore borders */
|
||||
:root[data-theme="light"] .chat-bubble {
|
||||
border-color: var(--border);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.chat-bubble:hover {
|
||||
background: var(--bg-hover);
|
||||
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover));
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* User bubbles have different styling */
|
||||
.chat-group.user .chat-bubble {
|
||||
background: var(--accent-subtle);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-group.user .chat-bubble {
|
||||
border-color: rgba(234, 88, 12, 0.2);
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 85%, var(--secondary));
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.chat-group.user .chat-bubble:hover {
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
/* Streaming animation */
|
||||
@@ -298,3 +293,59 @@ img.chat-avatar {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Delete button (appears on hover in group footer) */
|
||||
|
||||
.chat-group-delete {
|
||||
all: unset;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease-out,
|
||||
color 120ms ease-out,
|
||||
background 120ms ease-out;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-group-delete svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chat-group:hover .chat-group-delete {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chat-group-delete:hover {
|
||||
opacity: 1 !important;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.chat-group-delete:focus-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.chat-group-delete {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,15 @@
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 4px;
|
||||
padding: 14px 8px;
|
||||
margin: 0 -4px;
|
||||
min-height: 0; /* Allow shrinking for flex scroll behavior */
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--panel) 72%, transparent),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* Focus mode exit button */
|
||||
@@ -111,20 +115,22 @@
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text);
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel-strong) 92%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 86%, transparent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
border-color 150ms ease-out,
|
||||
box-shadow 150ms ease-out;
|
||||
}
|
||||
|
||||
.chat-new-messages:hover {
|
||||
background: var(--panel);
|
||||
border-color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 36%, transparent);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.chat-new-messages svg {
|
||||
@@ -147,8 +153,9 @@
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 12px 4px 4px;
|
||||
background: linear-gradient(to bottom, transparent, var(--bg) 20%);
|
||||
padding: 14px 6px 6px;
|
||||
background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -218,21 +225,6 @@
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
/* Light theme attachment overrides */
|
||||
:root[data-theme="light"] .chat-attachments {
|
||||
background: #f8fafc;
|
||||
border-color: rgba(16, 24, 40, 0.1);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment {
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment__remove {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Message images (sent images displayed in chat) */
|
||||
.chat-message-images {
|
||||
display: flex;
|
||||
@@ -267,10 +259,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-compose {
|
||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||
}
|
||||
|
||||
.chat-compose__field {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -290,13 +278,16 @@
|
||||
min-height: 40px;
|
||||
max-height: 150px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
border: 1px solid color-mix(in srgb, var(--input) 92%, transparent);
|
||||
background: color-mix(in srgb, var(--card) 98%, transparent);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
|
||||
.chat-compose__field textarea:disabled {
|
||||
@@ -351,25 +342,22 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--secondary) 85%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Controls separator */
|
||||
.chat-controls__separator {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
color: var(--border);
|
||||
font-size: 18px;
|
||||
margin: 0 8px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-controls__separator {
|
||||
color: rgba(16, 24, 40, 0.3);
|
||||
}
|
||||
|
||||
.btn--icon:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Ensure chat toolbar toggles have a clearly visible active state. */
|
||||
@@ -379,27 +367,6 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-controls .btn--icon.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn--icon svg {
|
||||
display: block;
|
||||
width: 18px;
|
||||
@@ -425,15 +392,9 @@
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Light theme thinking indicator override */
|
||||
:root[data-theme="light"] .chat-controls__thinking {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slide-in 200ms ease-out;
|
||||
background: color-mix(in srgb, var(--panel) 94%, transparent);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
@@ -50,12 +51,13 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--panel);
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* Smaller close button for sidebar */
|
||||
@@ -79,12 +81,13 @@
|
||||
|
||||
.sidebar-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@@ -5,17 +5,12 @@
|
||||
.chat-thinking {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed color-mix(in srgb, var(--border) 84%, transparent);
|
||||
background: color-mix(in srgb, var(--secondary) 75%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
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);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
@@ -57,14 +52,16 @@
|
||||
}
|
||||
|
||||
.chat-text :where(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--secondary) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
padding: 0.15em 0.42em;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.chat-text :where(pre) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 6px;
|
||||
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);
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -74,12 +71,50 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Collapsed JSON code blocks */
|
||||
|
||||
.chat-text :where(details.json-collapse) {
|
||||
background: color-mix(in srgb, var(--secondary) 82%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-text :where(details.json-collapse > summary) {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chat-text :where(details.json-collapse > summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-text :where(details.json-collapse > summary::before) {
|
||||
content: "▸ ";
|
||||
}
|
||||
|
||||
.chat-text :where(details.json-collapse[open] > summary::before) {
|
||||
content: "▾ ";
|
||||
}
|
||||
|
||||
.chat-text :where(details.json-collapse > pre) {
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
border-left: 3px solid color-mix(in srgb, var(--border-strong) 88%, transparent);
|
||||
padding-left: 12px;
|
||||
margin-left: 0;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
background: color-mix(in srgb, var(--secondary) 78%, transparent);
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
@@ -87,34 +122,12 @@
|
||||
.chat-text :where(blockquote blockquote) {
|
||||
margin-top: 8px;
|
||||
border-left-color: var(--border-hover);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: color-mix(in srgb, var(--secondary) 55%, transparent);
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote blockquote blockquote) {
|
||||
border-left-color: var(--muted-strong);
|
||||
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);
|
||||
background: color-mix(in srgb, var(--secondary) 60%, transparent);
|
||||
}
|
||||
|
||||
.chat-text :where(hr) {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/* Tool Card Styles */
|
||||
.chat-tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
background: var(--card);
|
||||
background: color-mix(in srgb, var(--card) 97%, transparent);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
transition:
|
||||
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 */
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
@@ -16,7 +17,8 @@
|
||||
|
||||
.chat-tool-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
background: color-mix(in srgb, var(--card) 82%, var(--bg-hover));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* First tool card in a group - no top margin */
|
||||
@@ -128,13 +130,13 @@
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--secondary);
|
||||
background: color-mix(in srgb, var(--secondary) 92%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
max-height: 44px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
}
|
||||
|
||||
.chat-tool-card--clickable:hover .chat-tool-card__preview {
|
||||
@@ -148,16 +150,18 @@
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--secondary);
|
||||
background: color-mix(in srgb, var(--secondary) 92%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Reading Indicator */
|
||||
.chat-reading-indicator {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px;
|
||||
display: inline-flex;
|
||||
}
|
||||
@@ -200,3 +204,176 @@
|
||||
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
@@ -27,10 +27,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-sidebar {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.config-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -41,7 +37,7 @@
|
||||
|
||||
.config-sidebar__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@@ -75,7 +71,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color var(--duration-fast) ease,
|
||||
@@ -93,14 +89,6 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-search__input {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-search__input:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-search__clear {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
@@ -145,7 +133,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -159,10 +147,6 @@
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-nav__item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.config-nav__item.active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
@@ -206,10 +190,6 @@
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-mode-toggle {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-mode-toggle__btn {
|
||||
flex: 1;
|
||||
padding: 9px 14px;
|
||||
@@ -260,10 +240,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-actions {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.config-actions__left,
|
||||
.config-actions__right {
|
||||
display: flex;
|
||||
@@ -275,7 +251,7 @@
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--accent-subtle);
|
||||
border: 1px solid rgba(255, 77, 77, 0.3);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -289,7 +265,7 @@
|
||||
/* Diff Panel */
|
||||
.config-diff {
|
||||
margin: 18px 22px 0;
|
||||
border: 1px solid rgba(255, 77, 77, 0.25);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--accent-subtle);
|
||||
overflow: hidden;
|
||||
@@ -343,10 +319,6 @@
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-diff__item {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-diff__path {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
@@ -384,10 +356,6 @@
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-hero {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.config-section-hero__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
@@ -411,7 +379,7 @@
|
||||
}
|
||||
|
||||
.config-section-hero__title {
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
@@ -420,7 +388,7 @@
|
||||
}
|
||||
|
||||
.config-section-hero__desc {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -434,10 +402,6 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-subnav {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.config-subnav__item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
@@ -454,10 +418,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-subnav__item {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-subnav__item:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
@@ -551,10 +511,6 @@
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-card {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.config-section-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -564,10 +520,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .config-section-card__header {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.config-section-card__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -587,7 +539,7 @@
|
||||
|
||||
.config-section-card__title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
@@ -597,7 +549,7 @@
|
||||
|
||||
.config-section-card__desc {
|
||||
margin: 5px 0 0;
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -624,23 +576,23 @@
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--danger-subtle);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.cfg-field__label {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cfg-field__help {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.cfg-field__error {
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -675,14 +627,6 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-input {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-input:focus {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-input--sm {
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
@@ -733,10 +677,6 @@
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-textarea {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-textarea--sm {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
@@ -751,10 +691,6 @@
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-number__btn {
|
||||
width: 44px;
|
||||
border: none;
|
||||
@@ -775,14 +711,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number__btn {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-number__btn:hover:not(:disabled) {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.cfg-number__input {
|
||||
width: 85px;
|
||||
padding: 11px;
|
||||
@@ -825,10 +753,6 @@
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-select {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* Segmented Control */
|
||||
.cfg-segmented {
|
||||
display: inline-flex;
|
||||
@@ -838,17 +762,13 @@
|
||||
background: var(--bg-accent);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-segmented {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-segmented__btn {
|
||||
padding: 9px 18px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -898,14 +818,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle-row {
|
||||
background: white;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle-row:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-toggle-row__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -913,7 +825,7 @@
|
||||
|
||||
.cfg-toggle-row__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -921,7 +833,7 @@
|
||||
.cfg-toggle-row__help {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -952,10 +864,6 @@
|
||||
border-color var(--duration-normal) ease;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-toggle__track {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.cfg-toggle__track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -973,7 +881,7 @@
|
||||
|
||||
.cfg-toggle input:checked + .cfg-toggle__track {
|
||||
background: var(--ok-subtle);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
border-color: color-mix(in srgb, var(--ok) 40%, transparent);
|
||||
}
|
||||
|
||||
.cfg-toggle input:checked + .cfg-toggle__track::after {
|
||||
@@ -993,10 +901,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-object {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-object__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1066,10 +970,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-array__header {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-array__label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
@@ -1085,10 +985,6 @@
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-array__count {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.cfg-array__add {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1156,10 +1052,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-array__item-header {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-array__item-index {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -1220,10 +1112,6 @@
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .cfg-map__header {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.cfg-map__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -1320,7 +1208,7 @@
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
border-color: color-mix(in srgb, var(--ok) 35%, transparent);
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
@@ -1444,3 +1332,85 @@
|
||||
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;
|
||||
}
|
||||
|
||||
554
ui/src/styles/glass.css
Normal file
554
ui/src/styles/glass.css
Normal file
@@ -0,0 +1,554 @@
|
||||
/* ════════════════════════════════════════════════════════
|
||||
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);
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 56px;
|
||||
--shell-nav-width: 240px;
|
||||
--shell-topbar-height: 62px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
@@ -14,7 +14,7 @@
|
||||
grid-template-columns: var(--shell-nav-width) minmax(0, 1fr);
|
||||
grid-template-rows: var(--shell-topbar-height) 1fr;
|
||||
grid-template-areas:
|
||||
"topbar topbar"
|
||||
"nav topbar"
|
||||
"nav content";
|
||||
gap: 0;
|
||||
animation: dashboard-enter 0.4s var(--ease-out);
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
.shell--nav-collapsed {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
grid-template-columns: 60px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--chat-focus {
|
||||
@@ -80,139 +80,262 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
border-bottom: var(--topbar-border);
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
/* --- Left: Dashboard Header --- */
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.topbar .nav-collapse-toggle__icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Brand */
|
||||
.brand {
|
||||
.dashboard-header__breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
.dashboard-header__breadcrumb-link {
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Topbar status */
|
||||
.topbar-status {
|
||||
.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;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 6px 10px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* --- Center: Search / Command Palette Trigger --- */
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
.topbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-top: 0px;
|
||||
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-status .statusDot {
|
||||
.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;
|
||||
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-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Connection indicator */
|
||||
|
||||
.topbar-connection {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
transition:
|
||||
color 250ms ease,
|
||||
background 250ms ease;
|
||||
}
|
||||
|
||||
.topbar-connection--ok {
|
||||
color: var(--ok);
|
||||
background: var(--ok-subtle);
|
||||
}
|
||||
|
||||
.topbar-connection__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 6px currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-connection:not(.topbar-connection--ok) .topbar-connection__dot {
|
||||
animation: pulse-subtle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.topbar-connection__label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Redact / stream-mode toggle */
|
||||
|
||||
.topbar-redact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 180ms ease,
|
||||
background 180ms ease,
|
||||
border-color 180ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-redact svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.topbar-redact:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--secondary) 80%, transparent);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.topbar-redact--active {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.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 {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.topbar-status .theme-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.nav {
|
||||
.sidebar {
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg);
|
||||
scrollbar-width: none; /* Firefox */
|
||||
scrollbar-width: none;
|
||||
background: var(--sidebar-bg);
|
||||
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
transition:
|
||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
opacity var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
.sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
.shell--chat-focus .sidebar {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
@@ -221,51 +344,141 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav--collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
.sidebar--collapsed {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Nav collapse toggle */
|
||||
.nav-collapse-toggle {
|
||||
width: 32px;
|
||||
.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;
|
||||
|
||||
max-height: 28px;
|
||||
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-brand__logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-brand__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg);
|
||||
border: var(--border) 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
margin-bottom: 16px;
|
||||
border-color var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
.sidebar--collapsed .sidebar-collapse-btn {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
transition: color var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.sidebar-collapse-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -273,13 +486,22 @@
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
||||
color: var(--text);
|
||||
/* Sidebar nav section */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav groups */
|
||||
.nav-group {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -297,16 +519,16 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav label */
|
||||
.nav-label {
|
||||
/* Nav group label */
|
||||
.nav-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
@@ -314,37 +536,40 @@
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
.nav-group__label:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-label--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-label--static:hover {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-label__text {
|
||||
.nav-group__label-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-label__chevron {
|
||||
font-size: 10px;
|
||||
.nav-group__chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.5;
|
||||
transition: transform var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-label__chevron {
|
||||
transform: rotate(-90deg);
|
||||
.nav-group__chevron svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Nav items */
|
||||
@@ -354,7 +579,7 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
@@ -364,12 +589,13 @@
|
||||
transition:
|
||||
border-color 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 {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -379,8 +605,8 @@
|
||||
}
|
||||
|
||||
.nav-item__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -389,14 +615,32 @@
|
||||
}
|
||||
|
||||
.nav-item__text {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item__external-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.nav-item__external-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
border-color: color-mix(in srgb, var(--border) 75%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -404,23 +648,55 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
.nav-item--active {
|
||||
color: var(--text-strong);
|
||||
background: var(--accent-subtle);
|
||||
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--secondary));
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-item__icon {
|
||||
.nav-item--active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Sidebar footer — aligned with chat compose bar */
|
||||
.sidebar-footer {
|
||||
padding: 14px 8px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.sidebar-version__text {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sidebar-version__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Content Area
|
||||
=========================================== */
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 12px 16px 32px;
|
||||
padding: 14px 18px 36px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -431,10 +707,6 @@
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .content {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -453,7 +725,7 @@
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
@@ -473,7 +745,7 @@
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
@@ -482,7 +754,7 @@
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
@@ -577,16 +849,31 @@
|
||||
"content";
|
||||
}
|
||||
|
||||
.nav {
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
@@ -606,8 +893,12 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-search__kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
|
||||
@@ -4,7 +4,22 @@
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
@media (max-width: 1100px) {
|
||||
.nav {
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
@@ -15,7 +30,7 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -27,7 +42,7 @@
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
.nav-group__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -56,53 +71,56 @@
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
.sidebar-brand__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
.dashboard-header__breadcrumb-link,
|
||||
.dashboard-header__breadcrumb-sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.topbar-search__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-search__kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-connection__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-divider {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topbar-status .pill .mono {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-status .pill span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
.sidebar-nav {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -110,7 +128,7 @@
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
.nav-group__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -288,11 +306,13 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.theme-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
@@ -311,11 +331,11 @@
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
.sidebar-brand__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
.sidebar-nav {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
@@ -356,15 +376,12 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.topbar-status .pill {
|
||||
.topbar-connection {
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 2px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
|
||||
@@ -50,7 +50,7 @@ function createHost() {
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
theme: "dark",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
parseExecApprovalResolved,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadHealthState } from "./controllers/health.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway.ts";
|
||||
@@ -33,7 +34,7 @@ import type { UiSettings } from "./storage.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
PresenceEntry,
|
||||
HealthSnapshot,
|
||||
HealthSummary,
|
||||
StatusSummary,
|
||||
UpdateAvailable,
|
||||
} from "./types.ts";
|
||||
@@ -55,7 +56,10 @@ type GatewayHost = {
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
debugHealth: HealthSummary | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
@@ -156,6 +160,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void loadAssistantIdentity(host as unknown as OpenClawApp);
|
||||
void loadAgents(host as unknown as OpenClawApp);
|
||||
void loadHealthState(host as unknown as OpenClawApp);
|
||||
void loadNodes(host as unknown as OpenClawApp, { quiet: true });
|
||||
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||
@@ -201,7 +206,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...host.eventLogBuffer,
|
||||
].slice(0, 250);
|
||||
if (host.tab === "debug") {
|
||||
if (host.tab === "debug" || host.tab === "overview") {
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
|
||||
@@ -293,7 +298,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
const snapshot = hello.snapshot as
|
||||
| {
|
||||
presence?: PresenceEntry[];
|
||||
health?: HealthSnapshot;
|
||||
health?: HealthSummary;
|
||||
sessionDefaults?: SessionDefaultsSnapshot;
|
||||
updateAvailable?: UpdateAvailable;
|
||||
}
|
||||
@@ -303,6 +308,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
}
|
||||
if (snapshot?.health) {
|
||||
host.debugHealth = snapshot.health;
|
||||
host.healthResult = snapshot.health;
|
||||
}
|
||||
if (snapshot?.sessionDefaults) {
|
||||
applySessionDefaults(host, snapshot.sessionDefaults);
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { observeTopbar, scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
|
||||
import {
|
||||
applySettingsFromUrl,
|
||||
attachThemeListener,
|
||||
detachThemeListener,
|
||||
inferBasePath,
|
||||
syncTabWithLocation,
|
||||
syncThemeWithSettings,
|
||||
@@ -38,14 +36,28 @@ type LifecycleHost = {
|
||||
topbarObserver: ResizeObserver | null;
|
||||
};
|
||||
|
||||
function handleCmdK(host: LifecycleHost, e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
(host as unknown as { paletteOpen: boolean }).paletteOpen = !(
|
||||
host as unknown as { paletteOpen: boolean }
|
||||
).paletteOpen;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleConnected(host: LifecycleHost) {
|
||||
host.basePath = inferBasePath();
|
||||
void loadControlUiBootstrapConfig(host);
|
||||
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
|
||||
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
|
||||
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
|
||||
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
|
||||
window.addEventListener("popstate", host.popStateHandler);
|
||||
(host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler = (e) =>
|
||||
handleCmdK(host, e);
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(host as unknown as { cmdKHandler: (e: KeyboardEvent) => void }).cmdKHandler,
|
||||
);
|
||||
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
|
||||
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
|
||||
if (host.tab === "logs") {
|
||||
@@ -62,10 +74,13 @@ export function handleFirstUpdated(host: LifecycleHost) {
|
||||
|
||||
export function handleDisconnected(host: LifecycleHost) {
|
||||
window.removeEventListener("popstate", host.popStateHandler);
|
||||
const cmdK = (host as unknown as { cmdKHandler?: (e: KeyboardEvent) => void }).cmdKHandler;
|
||||
if (cmdK) {
|
||||
window.removeEventListener("keydown", cmdK);
|
||||
}
|
||||
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
||||
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
|
||||
host.topbarObserver?.disconnect();
|
||||
host.topbarObserver = null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
@@ -49,10 +49,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
|
||||
|
||||
export function renderTab(state: AppViewState, tab: Tab) {
|
||||
const href = pathForTab(tab, state.basePath);
|
||||
const isActive = state.tab === tab;
|
||||
const collapsed = state.settings.navCollapsed;
|
||||
return html`
|
||||
<a
|
||||
href=${href}
|
||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
||||
class="nav-item ${isActive ? "nav-item--active" : ""}"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
@@ -77,7 +79,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
||||
title=${titleForTab(tab)}
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
||||
<span class="nav-item__text">${titleForTab(tab)}</span>
|
||||
${!collapsed ? html`<span class="nav-item__text">${titleForTab(tab)}</span>` : nothing}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
@@ -394,10 +396,18 @@ function resolveSessionOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
||||
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "dark", label: "Dark", iconKey: "monitor" },
|
||||
{ id: "light", label: "Light", iconKey: "book" },
|
||||
{ id: "openknot", label: "Knot", iconKey: "zap" },
|
||||
{ id: "fieldmanual", label: "Field", iconKey: "terminal" },
|
||||
{ id: "openai", label: "Ember", iconKey: "loader" },
|
||||
{ id: "clawdash", label: "Chrome", iconKey: "settings" },
|
||||
];
|
||||
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
|
||||
const app = state as unknown as OpenClawApp;
|
||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const context: ThemeTransitionContext = { element };
|
||||
@@ -408,74 +418,34 @@ export function renderThemeToggle(state: AppViewState) {
|
||||
state.setTheme(next, context);
|
||||
};
|
||||
|
||||
const handleCollapse = () => app.handleThemeToggleCollapse();
|
||||
|
||||
return html`
|
||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
||||
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
||||
<span class="theme-toggle__indicator"></span>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
|
||||
@click=${applyTheme("system")}
|
||||
aria-pressed=${state.theme === "system"}
|
||||
aria-label="System theme"
|
||||
title="System"
|
||||
>
|
||||
${renderMonitorIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
|
||||
@click=${applyTheme("light")}
|
||||
aria-pressed=${state.theme === "light"}
|
||||
aria-label="Light theme"
|
||||
title="Light"
|
||||
>
|
||||
${renderSunIcon()}
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
|
||||
@click=${applyTheme("dark")}
|
||||
aria-pressed=${state.theme === "dark"}
|
||||
aria-label="Dark theme"
|
||||
title="Dark"
|
||||
>
|
||||
${renderMoonIcon()}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="theme-toggle"
|
||||
@mouseleave=${handleCollapse}
|
||||
@focusout=${(e: FocusEvent) => {
|
||||
const toggle = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (!toggle.contains(document.activeElement)) {
|
||||
handleCollapse();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
${state.themeOrder.map((id) => {
|
||||
const opt = THEME_OPTIONS.find((o) => o.id === id)!;
|
||||
return html`
|
||||
<button
|
||||
class="theme-btn ${state.theme === id ? "active" : ""}"
|
||||
@click=${applyTheme(id)}
|
||||
aria-pressed=${state.theme === id}
|
||||
title=${opt.label}
|
||||
>
|
||||
${icons[opt.iconKey]}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
parseAgentSessionKey,
|
||||
} from "../../../src/routing/session-key.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.ts";
|
||||
import { renderUsageTab } from "./app-render-usage-tab.ts";
|
||||
@@ -52,17 +55,21 @@ import {
|
||||
updateSkillEdit,
|
||||
updateSkillEnabled,
|
||||
} from "./controllers/skills.ts";
|
||||
import "./components/dashboard-header.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import { renderAgents } from "./views/agents.ts";
|
||||
import { renderBottomTabs } from "./views/bottom-tabs.ts";
|
||||
import { renderChannels } from "./views/channels.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
import { renderCommandPalette } from "./views/command-palette.ts";
|
||||
import { renderConfig } from "./views/config.ts";
|
||||
import { renderCron } from "./views/cron.ts";
|
||||
import { renderDebug } from "./views/debug.ts";
|
||||
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
|
||||
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
|
||||
import { renderInstances } from "./views/instances.ts";
|
||||
import { renderLoginGate } from "./views/login-gate.ts";
|
||||
import { renderLogs } from "./views/logs.ts";
|
||||
import { renderNodes } from "./views/nodes.ts";
|
||||
import { renderOverview } from "./views/overview.ts";
|
||||
@@ -89,6 +96,15 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
}
|
||||
|
||||
export function renderApp(state: AppViewState) {
|
||||
// Gate: require successful gateway connection before showing the dashboard.
|
||||
// The gateway URL confirmation overlay is always rendered so URL-param flows still work.
|
||||
if (!state.connected) {
|
||||
return html`
|
||||
${renderLoginGate(state)}
|
||||
${renderGatewayUrlConfirmation(state)}
|
||||
`;
|
||||
}
|
||||
|
||||
const presenceCount = state.presenceEntries.length;
|
||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||
const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
|
||||
@@ -108,83 +124,165 @@ export function renderApp(state: AppViewState) {
|
||||
null;
|
||||
|
||||
return html`
|
||||
${renderCommandPalette({
|
||||
open: state.paletteOpen,
|
||||
query: (state as unknown as { paletteQuery?: string }).paletteQuery ?? "",
|
||||
activeIndex: (state as unknown as { paletteActiveIndex?: number }).paletteActiveIndex ?? 0,
|
||||
onToggle: () => {
|
||||
state.paletteOpen = !state.paletteOpen;
|
||||
},
|
||||
onQueryChange: (q) => {
|
||||
(state as unknown as { paletteQuery: string }).paletteQuery = q;
|
||||
},
|
||||
onActiveIndexChange: (i) => {
|
||||
(state as unknown as { paletteActiveIndex: number }).paletteActiveIndex = i;
|
||||
},
|
||||
onNavigate: (tab) => {
|
||||
state.setTab(tab as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSlashCommand: (_cmd) => {
|
||||
state.setTab("chat" as import("./navigation.ts").Tab);
|
||||
},
|
||||
})}
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button
|
||||
class="nav-collapse-toggle"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
>
|
||||
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
|
||||
</button>
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
<img src=${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"} alt="OpenClaw" />
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">OPENCLAW</div>
|
||||
<div class="brand-sub">Gateway Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<dashboard-header .tab=${state.tab}></dashboard-header>
|
||||
<button
|
||||
class="topbar-search"
|
||||
@click=${() => {
|
||||
state.paletteOpen = !state.paletteOpen;
|
||||
}}
|
||||
title="Search or jump to… (⌘K)"
|
||||
aria-label="Open command palette"
|
||||
>
|
||||
<span class="topbar-search__label">${t("common.search")}</span>
|
||||
<kbd class="topbar-search__kbd">⌘K</kbd>
|
||||
</button>
|
||||
<div class="topbar-status">
|
||||
<div class="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>
|
||||
<button
|
||||
class="topbar-redact ${state.streamMode ? "topbar-redact--active" : ""}"
|
||||
@click=${() => {
|
||||
state.streamMode = !state.streamMode;
|
||||
try {
|
||||
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}}
|
||||
title="${state.streamMode ? "Sensitive data hidden — click to reveal" : "Sensitive data visible — click to hide"}"
|
||||
aria-label="Toggle redaction"
|
||||
aria-pressed=${state.streamMode}
|
||||
>
|
||||
${state.streamMode ? icons.eye : icons.eyeOff}
|
||||
</button>
|
||||
<span class="topbar-divider"></span>
|
||||
<div class="topbar-connection ${state.connected ? "topbar-connection--ok" : ""}">
|
||||
<span class="topbar-connection__dot"></span>
|
||||
<span class="topbar-connection__label">${state.connected ? t("common.ok") : t("common.offline")}</span>
|
||||
</div>
|
||||
<span class="topbar-divider"></span>
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
<aside class="nav ${state.settings.navCollapsed ? "nav--collapsed" : ""}">
|
||||
${TAB_GROUPS.map((group) => {
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
return html`
|
||||
<div class="nav-group ${isGroupCollapsed && !hasActiveTab ? "nav-group--collapsed" : ""}">
|
||||
<button
|
||||
class="nav-label"
|
||||
@click=${() => {
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
aria-expanded=${!isGroupCollapsed}
|
||||
>
|
||||
<span class="nav-label__text">${t(`nav.${group.label}`)}</span>
|
||||
<span class="nav-label__chevron">${isGroupCollapsed ? "+" : "−"}</span>
|
||||
</button>
|
||||
<div class="nav-group__items">
|
||||
${group.tabs.map((tab) => renderTab(state, tab))}
|
||||
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
|
||||
<div class="sidebar-header">
|
||||
${
|
||||
state.settings.navCollapsed
|
||||
? nothing
|
||||
: html`
|
||||
<div class="sidebar-brand">
|
||||
<img class="sidebar-brand__logo" src="${basePath ? `${basePath}/favicon.svg` : "/favicon.svg"}" alt="OpenClaw" />
|
||||
<span class="sidebar-brand__title">OpenClaw</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
@click=${() =>
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navCollapsed: !state.settings.navCollapsed,
|
||||
})}
|
||||
title="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
aria-label="${state.settings.navCollapsed ? t("nav.expand") : t("nav.collapse")}"
|
||||
>
|
||||
${state.settings.navCollapsed ? icons.panelLeftOpen : icons.panelLeftClose}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
${TAB_GROUPS.map((group) => {
|
||||
const isGroupCollapsed = state.settings.navGroupsCollapsed[group.label] ?? false;
|
||||
const hasActiveTab = group.tabs.some((tab) => tab === state.tab);
|
||||
const showItems = hasActiveTab || !isGroupCollapsed;
|
||||
|
||||
return html`
|
||||
<div class="nav-group ${!showItems ? "nav-group--collapsed" : ""}">
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`
|
||||
<button
|
||||
class="nav-group__label"
|
||||
@click=${() => {
|
||||
const next = { ...state.settings.navGroupsCollapsed };
|
||||
next[group.label] = !isGroupCollapsed;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
navGroupsCollapsed: next,
|
||||
});
|
||||
}}
|
||||
aria-expanded=${showItems}
|
||||
>
|
||||
<span class="nav-group__label-text">${t(`nav.${group.label}`)}</span>
|
||||
<span class="nav-group__chevron">${showItems ? icons.chevronDown : icons.chevronRight}</span>
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="nav-group__items">
|
||||
${group.tabs.map((tab) => renderTab(state, tab))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<div class="nav-group nav-group--links">
|
||||
<div class="nav-label nav-label--static">
|
||||
<span class="nav-label__text">${t("common.resources")}</span>
|
||||
</div>
|
||||
<div class="nav-group__items">
|
||||
<a
|
||||
class="nav-item nav-item--external"
|
||||
href="https://docs.openclaw.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="${t("common.docs")} (opens in new tab)"
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||
`;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a
|
||||
class="nav-item nav-item--external"
|
||||
href="https://docs.openclaw.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="${t("common.docs")} (opens in new tab)"
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`
|
||||
<span class="nav-item__text">${t("common.docs")}</span>
|
||||
</a>
|
||||
</div>
|
||||
<span class="nav-item__external-icon">${icons.externalLink}</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</a>
|
||||
${(() => {
|
||||
const snapshot = state.hello?.snapshot as { server?: { version?: string } } | undefined;
|
||||
const version = snapshot?.server?.version ?? "";
|
||||
return version
|
||||
? html`
|
||||
<div class="sidebar-version" title=${`v${version}`}>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`<span class="sidebar-version__text">v${version}</span>`
|
||||
: html`
|
||||
<span class="sidebar-version__dot"></span>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing;
|
||||
})()}
|
||||
</div>
|
||||
</aside>
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
@@ -225,6 +323,15 @@ export function renderApp(state: AppViewState) {
|
||||
cronEnabled: state.cronStatus?.enabled ?? null,
|
||||
cronNext,
|
||||
lastChannelsRefresh: state.channelsLastSuccess,
|
||||
usageResult: state.usageResult,
|
||||
sessionsResult: state.sessionsResult,
|
||||
skillsReport: state.skillsReport,
|
||||
cronJobs: state.cronJobs,
|
||||
cronStatus: state.cronStatus,
|
||||
attentionItems: state.attentionItems,
|
||||
eventLog: state.eventLog,
|
||||
overviewLogLines: state.overviewLogLines,
|
||||
streamMode: state.streamMode,
|
||||
onSettingsChange: (next) => state.applySettings(next),
|
||||
onPasswordChange: (next) => (state.password = next),
|
||||
onSessionKeyChange: (next) => {
|
||||
@@ -240,6 +347,16 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
onConnect: () => state.connect(),
|
||||
onRefresh: () => state.loadOverview(),
|
||||
onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
|
||||
onRefreshLogs: () => state.loadOverview(),
|
||||
onToggleStreamMode: () => {
|
||||
state.streamMode = !state.streamMode;
|
||||
try {
|
||||
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -290,6 +407,7 @@ export function renderApp(state: AppViewState) {
|
||||
entries: state.presenceEntries,
|
||||
lastError: state.presenceError,
|
||||
statusMessage: state.presenceStatus,
|
||||
streamMode: state.streamMode,
|
||||
onRefresh: () => loadPresence(state),
|
||||
})
|
||||
: nothing
|
||||
@@ -358,33 +476,47 @@ export function renderApp(state: AppViewState) {
|
||||
agentsList: state.agentsList,
|
||||
selectedAgentId: resolvedAgentId,
|
||||
activePanel: state.agentsPanel,
|
||||
configForm: configValue,
|
||||
configLoading: state.configLoading,
|
||||
configSaving: state.configSaving,
|
||||
configDirty: state.configFormDirty,
|
||||
channelsLoading: state.channelsLoading,
|
||||
channelsError: state.channelsError,
|
||||
channelsSnapshot: state.channelsSnapshot,
|
||||
channelsLastSuccess: state.channelsLastSuccess,
|
||||
cronLoading: state.cronLoading,
|
||||
cronStatus: state.cronStatus,
|
||||
cronJobs: state.cronJobs,
|
||||
cronError: state.cronError,
|
||||
agentFilesLoading: state.agentFilesLoading,
|
||||
agentFilesError: state.agentFilesError,
|
||||
agentFilesList: state.agentFilesList,
|
||||
agentFileActive: state.agentFileActive,
|
||||
agentFileContents: state.agentFileContents,
|
||||
agentFileDrafts: state.agentFileDrafts,
|
||||
agentFileSaving: state.agentFileSaving,
|
||||
config: {
|
||||
form: configValue,
|
||||
loading: state.configLoading,
|
||||
saving: state.configSaving,
|
||||
dirty: state.configFormDirty,
|
||||
},
|
||||
channels: {
|
||||
snapshot: state.channelsSnapshot,
|
||||
loading: state.channelsLoading,
|
||||
error: state.channelsError,
|
||||
lastSuccess: state.channelsLastSuccess,
|
||||
},
|
||||
cron: {
|
||||
status: state.cronStatus,
|
||||
jobs: state.cronJobs,
|
||||
loading: state.cronLoading,
|
||||
error: state.cronError,
|
||||
},
|
||||
agentFiles: {
|
||||
list: state.agentFilesList,
|
||||
loading: state.agentFilesLoading,
|
||||
error: state.agentFilesError,
|
||||
active: state.agentFileActive,
|
||||
contents: state.agentFileContents,
|
||||
drafts: state.agentFileDrafts,
|
||||
saving: state.agentFileSaving,
|
||||
},
|
||||
agentIdentityLoading: state.agentIdentityLoading,
|
||||
agentIdentityError: state.agentIdentityError,
|
||||
agentIdentityById: state.agentIdentityById,
|
||||
agentSkillsLoading: state.agentSkillsLoading,
|
||||
agentSkillsReport: state.agentSkillsReport,
|
||||
agentSkillsError: state.agentSkillsError,
|
||||
agentSkillsAgentId: state.agentSkillsAgentId,
|
||||
skillsFilter: state.skillsFilter,
|
||||
agentSkills: {
|
||||
report: state.agentSkillsReport,
|
||||
loading: state.agentSkillsLoading,
|
||||
error: state.agentSkillsError,
|
||||
agentId: state.agentSkillsAgentId,
|
||||
filter: state.skillsFilter,
|
||||
},
|
||||
sidebarFilter: state.agentsSidebarFilter,
|
||||
onSidebarFilterChange: (value) => {
|
||||
state.agentsSidebarFilter = value;
|
||||
},
|
||||
onRefresh: async () => {
|
||||
await loadAgents(state);
|
||||
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
@@ -523,6 +655,9 @@ export function renderApp(state: AppViewState) {
|
||||
onConfigSave: () => saveConfig(state),
|
||||
onChannelsRefresh: () => loadChannels(state, false),
|
||||
onCronRefresh: () => state.loadCron(),
|
||||
onCronRunNow: (_jobId) => {
|
||||
// Stub: backend support pending
|
||||
},
|
||||
onSkillsFilterChange: (next) => (state.skillsFilter = next),
|
||||
onSkillsRefresh: () => {
|
||||
if (resolvedAgentId) {
|
||||
@@ -692,6 +827,12 @@ export function renderApp(state: AppViewState) {
|
||||
: { fallbacks: normalized };
|
||||
updateConfigFormValue(state, basePath, next);
|
||||
},
|
||||
onSetDefault: (agentId) => {
|
||||
if (!configValue) {
|
||||
return;
|
||||
}
|
||||
updateConfigFormValue(state, ["agents", "defaultId"], agentId);
|
||||
},
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -860,6 +1001,45 @@ export function renderApp(state: AppViewState) {
|
||||
onAbort: () => void state.handleAbortChat(),
|
||||
onQueueRemove: (id) => state.removeQueuedMessage(id),
|
||||
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
|
||||
onClearHistory: async () => {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await state.client.request("sessions.reset", { key: state.sessionKey });
|
||||
state.chatMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
await loadChatHistory(state);
|
||||
} catch (err) {
|
||||
state.lastError = String(err);
|
||||
}
|
||||
},
|
||||
agentsList: state.agentsList,
|
||||
currentAgentId: resolvedAgentId ?? "main",
|
||||
onAgentChange: (agentId: string) => {
|
||||
state.sessionKey = buildAgentMainSessionKey({ agentId });
|
||||
state.chatMessages = [];
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: state.sessionKey,
|
||||
lastActiveSessionKey: state.sessionKey,
|
||||
});
|
||||
void loadChatHistory(state);
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
onNavigateToAgent: () => {
|
||||
state.agentsSelectedId = resolvedAgentId;
|
||||
state.setTab("agents" as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSessionSelect: (key: string) => {
|
||||
state.setSessionKey(key);
|
||||
state.chatMessages = [];
|
||||
void loadChatHistory(state);
|
||||
void state.loadAssistantIdentity();
|
||||
},
|
||||
showNewMessages: state.chatNewMessagesBelow && !state.chatManualRefreshInFlight,
|
||||
onScrollToBottom: () => state.scrollToBottom(),
|
||||
// Sidebar props for tool output viewing
|
||||
@@ -897,6 +1077,7 @@ export function renderApp(state: AppViewState) {
|
||||
searchQuery: state.configSearchQuery,
|
||||
activeSection: state.configActiveSection,
|
||||
activeSubsection: state.configActiveSubsection,
|
||||
streamMode: state.streamMode,
|
||||
onRawChange: (next) => {
|
||||
state.configRaw = next;
|
||||
},
|
||||
@@ -962,6 +1143,10 @@ export function renderApp(state: AppViewState) {
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
${renderGatewayUrlConfirmation(state)}
|
||||
${renderBottomTabs({
|
||||
activeTab: state.tab,
|
||||
onTabChange: (tab) => state.setTab(tab),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
theme: "dark",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
},
|
||||
theme: "system",
|
||||
theme: "dark",
|
||||
themeResolved: "dark",
|
||||
applySessionKey: "main",
|
||||
sessionKey: "main",
|
||||
@@ -31,8 +31,6 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
eventLog: [],
|
||||
eventLogBuffer: [],
|
||||
basePath: "",
|
||||
themeMedia: null,
|
||||
themeMediaHandler: null,
|
||||
logsPollInterval: null,
|
||||
debugPollInterval: null,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadPresence } from "./controllers/presence.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { loadSkills } from "./controllers/skills.ts";
|
||||
import { loadUsage } from "./controllers/usage.ts";
|
||||
import {
|
||||
inferBasePathFromPathname,
|
||||
normalizeBasePath,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
import { saveSettings, type UiSettings } from "./storage.ts";
|
||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
||||
import type { AgentsListResult } from "./types.ts";
|
||||
import type { AgentsListResult, AttentionItem } from "./types.ts";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
@@ -51,8 +52,6 @@ type SettingsHost = {
|
||||
agentsList?: AgentsListResult | null;
|
||||
agentsSelectedId?: string | null;
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -259,7 +258,7 @@ export function inferBasePath() {
|
||||
}
|
||||
|
||||
export function syncThemeWithSettings(host: SettingsHost) {
|
||||
host.theme = host.settings.theme ?? "system";
|
||||
host.theme = host.settings.theme ?? "dark";
|
||||
applyResolvedTheme(host, resolveTheme(host.theme));
|
||||
}
|
||||
|
||||
@@ -270,44 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
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;
|
||||
root.style.colorScheme = "dark";
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
@@ -403,13 +365,121 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as OpenClawApp, false),
|
||||
loadPresence(host as unknown as OpenClawApp),
|
||||
loadSessions(host as unknown as OpenClawApp),
|
||||
loadCronStatus(host as unknown as OpenClawApp),
|
||||
loadDebug(host as unknown as OpenClawApp),
|
||||
const app = host as unknown as OpenClawApp;
|
||||
await Promise.allSettled([
|
||||
loadChannels(app, false),
|
||||
loadPresence(app),
|
||||
loadSessions(app),
|
||||
loadCronStatus(app),
|
||||
loadCronJobs(app),
|
||||
loadDebug(app),
|
||||
loadSkills(app),
|
||||
loadUsage(app),
|
||||
loadOverviewLogs(app),
|
||||
]);
|
||||
buildAttentionItems(app);
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -8,20 +8,22 @@ import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import type { UiSettings } from "./storage.ts";
|
||||
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
AgentsFilesListResult,
|
||||
AgentIdentityResult,
|
||||
AttentionItem,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigSnapshot,
|
||||
ConfigUiHints,
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
HealthSummary,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ModelCatalogEntry,
|
||||
NostrProfile,
|
||||
PresenceEntry,
|
||||
SessionsUsageResult,
|
||||
@@ -43,7 +45,8 @@ export type AppViewState = {
|
||||
basePath: string;
|
||||
connected: boolean;
|
||||
theme: ThemeMode;
|
||||
themeResolved: "light" | "dark";
|
||||
themeResolved: ResolvedTheme;
|
||||
themeOrder: ThemeMode[];
|
||||
hello: GatewayHelloOk | null;
|
||||
lastError: string | null;
|
||||
eventLog: EventLogEntry[];
|
||||
@@ -143,6 +146,7 @@ export type AppViewState = {
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
agentsSidebarFilter: string;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
@@ -200,10 +204,13 @@ export type AppViewState = {
|
||||
skillEdits: Record<string, string>;
|
||||
skillMessages: Record<string, SkillMessage>;
|
||||
skillsBusyKey: string | null;
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHealth: HealthSummary | null;
|
||||
debugModels: ModelCatalogEntry[];
|
||||
debugHeartbeat: unknown;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
@@ -223,6 +230,12 @@ export type AppViewState = {
|
||||
logsMaxBytes: number;
|
||||
logsAtBottom: boolean;
|
||||
updateAvailable: import("./types.js").UpdateAvailable | null;
|
||||
// Overview dashboard state
|
||||
attentionItems: AttentionItem[];
|
||||
paletteOpen: boolean;
|
||||
streamMode: boolean;
|
||||
overviewLogLines: string[];
|
||||
overviewLogCursor: number;
|
||||
client: GatewayBrowserClient | null;
|
||||
refreshSessionsAfterChat: Set<string>;
|
||||
connect: () => void;
|
||||
|
||||
@@ -60,7 +60,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import type { Tab } from "./navigation.ts";
|
||||
import { loadSettings, type UiSettings } from "./storage.ts";
|
||||
import type { ResolvedTheme, ThemeMode } from "./theme.ts";
|
||||
import { VALID_THEMES, type ResolvedTheme, type ThemeMode } from "./theme.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
AgentsFilesListResult,
|
||||
@@ -70,9 +70,10 @@ import type {
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
HealthSummary,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ModelCatalogEntry,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
@@ -118,8 +119,9 @@ export class OpenClawApp extends LitElement {
|
||||
@state() tab: Tab = "chat";
|
||||
@state() onboarding = resolveOnboardingMode();
|
||||
@state() connected = false;
|
||||
@state() theme: ThemeMode = this.settings.theme ?? "system";
|
||||
@state() theme: ThemeMode = this.settings.theme ?? "dark";
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() themeOrder: ThemeMode[] = this.buildThemeOrder(this.theme);
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() eventLog: EventLogEntry[] = [];
|
||||
@@ -229,6 +231,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() agentSkillsError: string | null = null;
|
||||
@state() agentSkillsReport: SkillStatusReport | null = null;
|
||||
@state() agentSkillsAgentId: string | null = null;
|
||||
@state() agentsSidebarFilter = "";
|
||||
|
||||
@state() sessionsLoading = false;
|
||||
@state() sessionsResult: SessionsListResult | null = null;
|
||||
@@ -304,6 +307,23 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
|
||||
|
||||
// Overview dashboard state
|
||||
@state() attentionItems: import("./types.js").AttentionItem[] = [];
|
||||
@state() paletteOpen = false;
|
||||
paletteQuery = "";
|
||||
paletteActiveIndex = 0;
|
||||
@state() streamMode = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("openclaw:stream-mode");
|
||||
// Default to true (redacted) unless explicitly disabled
|
||||
return stored === null ? true : stored === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
@state() overviewLogLines: string[] = [];
|
||||
@state() overviewLogCursor = 0;
|
||||
|
||||
@state() skillsLoading = false;
|
||||
@state() skillsReport: SkillStatusReport | null = null;
|
||||
@state() skillsError: string | null = null;
|
||||
@@ -312,10 +332,14 @@ export class OpenClawApp extends LitElement {
|
||||
@state() skillsBusyKey: string | null = null;
|
||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||
|
||||
@state() healthLoading = false;
|
||||
@state() healthResult: HealthSummary | null = null;
|
||||
@state() healthError: string | null = null;
|
||||
|
||||
@state() debugLoading = false;
|
||||
@state() debugStatus: StatusSummary | null = null;
|
||||
@state() debugHealth: HealthSnapshot | null = null;
|
||||
@state() debugModels: unknown[] = [];
|
||||
@state() debugHealth: HealthSummary | null = null;
|
||||
@state() debugModels: ModelCatalogEntry[] = [];
|
||||
@state() debugHeartbeat: unknown = null;
|
||||
@state() debugCallMethod = "";
|
||||
@state() debugCallParams = "{}";
|
||||
@@ -354,8 +378,6 @@ export class OpenClawApp extends LitElement {
|
||||
basePath = "";
|
||||
private popStateHandler = () =>
|
||||
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
|
||||
private themeMedia: MediaQueryList | null = null;
|
||||
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
||||
private topbarObserver: ResizeObserver | null = null;
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -433,6 +455,19 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
|
||||
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
|
||||
this.themeOrder = this.buildThemeOrder(next);
|
||||
}
|
||||
|
||||
buildThemeOrder(active: ThemeMode): ThemeMode[] {
|
||||
const all = [...VALID_THEMES];
|
||||
const rest = all.filter((id) => id !== active);
|
||||
return [active, ...rest];
|
||||
}
|
||||
|
||||
handleThemeToggleCollapse() {
|
||||
setTimeout(() => {
|
||||
this.themeOrder = this.buildThemeOrder(this.theme);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
|
||||
49
ui/src/ui/chat/deleted-messages.ts
Normal file
49
ui/src/ui/chat/deleted-messages.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const PREFIX = "openclaw:deleted:";
|
||||
|
||||
export class DeletedMessages {
|
||||
private key: string;
|
||||
private _keys = new Set<string>();
|
||||
|
||||
constructor(sessionKey: string) {
|
||||
this.key = PREFIX + sessionKey;
|
||||
this.load();
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this._keys.has(key);
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this._keys.add(key);
|
||||
this.save();
|
||||
}
|
||||
|
||||
restore(key: string): void {
|
||||
this._keys.delete(key);
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._keys.clear();
|
||||
this.save();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.key);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr)) {
|
||||
this._keys = new Set(arr.filter((s) => typeof s === "string"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
@@ -111,6 +112,7 @@ export function renderMessageGroup(
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
onDelete?: () => void;
|
||||
},
|
||||
) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
@@ -148,6 +150,16 @@ export function renderMessageGroup(
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${who}</span>
|
||||
<span class="chat-group-timestamp">${timestamp}</span>
|
||||
${
|
||||
opts.onDelete
|
||||
? html`<button
|
||||
class="chat-group-delete"
|
||||
@click=${opts.onDelete}
|
||||
title="Delete"
|
||||
aria-label="Delete message"
|
||||
>${icons.x}</button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,6 +228,66 @@ function renderMessageImages(images: ImageBlock[]) {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Render tool cards inside a collapsed `<details>` element. */
|
||||
function renderCollapsedToolCards(
|
||||
toolCards: ToolCard[],
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
) {
|
||||
const calls = toolCards.filter((c) => c.kind === "call");
|
||||
const results = toolCards.filter((c) => c.kind === "result");
|
||||
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
|
||||
const toolNames = [...new Set(toolCards.map((c) => c.name))];
|
||||
const summaryLabel =
|
||||
toolNames.length <= 3
|
||||
? toolNames.join(", ")
|
||||
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
|
||||
|
||||
return html`
|
||||
<details class="chat-tools-collapse">
|
||||
<summary class="chat-tools-summary">
|
||||
<span class="chat-tools-summary__icon">${icons.zap}</span>
|
||||
<span class="chat-tools-summary__count">${totalTools} tool${totalTools === 1 ? "" : "s"}</span>
|
||||
<span class="chat-tools-summary__names">${summaryLabel}</span>
|
||||
</summary>
|
||||
<div class="chat-tools-collapse__body">
|
||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a trimmed string is a JSON object or array.
|
||||
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
|
||||
*/
|
||||
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
|
||||
const t = text.trim();
|
||||
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
||||
try {
|
||||
const parsed = JSON.parse(t);
|
||||
return { parsed, pretty: JSON.stringify(parsed, null, 2) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Build a short summary label for collapsed JSON (type + key count or array length). */
|
||||
function jsonSummaryLabel(parsed: unknown): string {
|
||||
if (Array.isArray(parsed)) {
|
||||
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`;
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const keys = Object.keys(parsed as Record<string, unknown>);
|
||||
if (keys.length <= 4) {
|
||||
return `{ ${keys.join(", ")} }`;
|
||||
}
|
||||
return `Object (${keys.length} keys)`;
|
||||
}
|
||||
return "JSON";
|
||||
}
|
||||
|
||||
function renderGroupedMessage(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
@@ -243,6 +315,9 @@ function renderGroupedMessage(
|
||||
const markdown = markdownBase;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
|
||||
// Detect pure-JSON messages and render as collapsible block
|
||||
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
canCopyMarkdown ? "has-copy" : "",
|
||||
@@ -253,7 +328,7 @@ function renderGroupedMessage(
|
||||
.join(" ");
|
||||
|
||||
if (!markdown && hasToolCards && isToolResult) {
|
||||
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
|
||||
return renderCollapsedToolCards(toolCards, onOpenSidebar);
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards && !hasImages) {
|
||||
@@ -272,11 +347,19 @@ function renderGroupedMessage(
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
markdown
|
||||
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing
|
||||
jsonResult
|
||||
? html`<details class="chat-json-collapse">
|
||||
<summary class="chat-json-summary">
|
||||
<span class="chat-json-badge">JSON</span>
|
||||
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
|
||||
</summary>
|
||||
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
|
||||
</details>`
|
||||
: markdown
|
||||
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||
: nothing
|
||||
}
|
||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
49
ui/src/ui/chat/input-history.ts
Normal file
49
ui/src/ui/chat/input-history.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
const MAX = 50;
|
||||
|
||||
export class InputHistory {
|
||||
private items: string[] = [];
|
||||
private cursor = -1;
|
||||
|
||||
push(text: string): void {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
if (this.items[this.items.length - 1] === trimmed) {
|
||||
return;
|
||||
}
|
||||
this.items.push(trimmed);
|
||||
if (this.items.length > MAX) {
|
||||
this.items.shift();
|
||||
}
|
||||
this.cursor = -1;
|
||||
}
|
||||
|
||||
up(): string | null {
|
||||
if (this.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (this.cursor < 0) {
|
||||
this.cursor = this.items.length - 1;
|
||||
} else if (this.cursor > 0) {
|
||||
this.cursor--;
|
||||
}
|
||||
return this.items[this.cursor] ?? null;
|
||||
}
|
||||
|
||||
down(): string | null {
|
||||
if (this.cursor < 0) {
|
||||
return null;
|
||||
}
|
||||
this.cursor++;
|
||||
if (this.cursor >= this.items.length) {
|
||||
this.cursor = -1;
|
||||
return null;
|
||||
}
|
||||
return this.items[this.cursor] ?? null;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.cursor = -1;
|
||||
}
|
||||
}
|
||||
61
ui/src/ui/chat/pinned-messages.ts
Normal file
61
ui/src/ui/chat/pinned-messages.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
const PREFIX = "openclaw:pinned:";
|
||||
|
||||
export class PinnedMessages {
|
||||
private key: string;
|
||||
private _indices = new Set<number>();
|
||||
|
||||
constructor(sessionKey: string) {
|
||||
this.key = PREFIX + sessionKey;
|
||||
this.load();
|
||||
}
|
||||
|
||||
get indices(): Set<number> {
|
||||
return this._indices;
|
||||
}
|
||||
|
||||
has(index: number): boolean {
|
||||
return this._indices.has(index);
|
||||
}
|
||||
|
||||
pin(index: number): void {
|
||||
this._indices.add(index);
|
||||
this.save();
|
||||
}
|
||||
|
||||
unpin(index: number): void {
|
||||
this._indices.delete(index);
|
||||
this.save();
|
||||
}
|
||||
|
||||
toggle(index: number): void {
|
||||
if (this._indices.has(index)) {
|
||||
this.unpin(index);
|
||||
} else {
|
||||
this.pin(index);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._indices.clear();
|
||||
this.save();
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(this.key);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr)) {
|
||||
this._indices = new Set(arr.filter((n) => typeof n === "number"));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
|
||||
}
|
||||
}
|
||||
84
ui/src/ui/chat/slash-commands.ts
Normal file
84
ui/src/ui/chat/slash-commands.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
34
ui/src/ui/components/dashboard-header.ts
Normal file
34
ui/src/ui/components/dashboard-header.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { titleForTab, type Tab } from "../navigation.js";
|
||||
|
||||
@customElement("dashboard-header")
|
||||
export class DashboardHeader extends LitElement {
|
||||
override createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property() tab: Tab = "overview";
|
||||
|
||||
override render() {
|
||||
const label = titleForTab(this.tab);
|
||||
|
||||
return html`
|
||||
<div class="dashboard-header">
|
||||
<div class="dashboard-header__breadcrumb">
|
||||
<span
|
||||
class="dashboard-header__breadcrumb-link"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
|
||||
>
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ describe("config form renderer", () => {
|
||||
expect(container.textContent).toContain("Plugin Enabled");
|
||||
});
|
||||
|
||||
it("flags unsupported unions", () => {
|
||||
it("passes mixed unions through for JSON fallback rendering", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -207,7 +207,7 @@ describe("config form renderer", () => {
|
||||
},
|
||||
};
|
||||
const analysis = analyzeConfigSchema(schema);
|
||||
expect(analysis.unsupportedPaths).toContain("mixed");
|
||||
expect(analysis.unsupportedPaths).not.toContain("mixed");
|
||||
});
|
||||
|
||||
it("supports nullable types", () => {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { HealthSnapshot, StatusSummary } from "../types.ts";
|
||||
import type { HealthSummary, ModelCatalogEntry, StatusSummary } from "../types.ts";
|
||||
import { loadHealthState } from "./health.ts";
|
||||
import { loadModels } from "./models.ts";
|
||||
|
||||
export type DebugState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
debugLoading: boolean;
|
||||
debugStatus: StatusSummary | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
debugModels: unknown[];
|
||||
debugHealth: HealthSummary | null;
|
||||
debugModels: ModelCatalogEntry[];
|
||||
debugHeartbeat: unknown;
|
||||
debugCallMethod: string;
|
||||
debugCallParams: string;
|
||||
debugCallResult: string | null;
|
||||
debugCallError: string | null;
|
||||
/** Shared health state fields (written by {@link loadHealthState}). */
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
};
|
||||
|
||||
export async function loadDebug(state: DebugState) {
|
||||
@@ -24,16 +30,16 @@ export async function loadDebug(state: DebugState) {
|
||||
}
|
||||
state.debugLoading = true;
|
||||
try {
|
||||
const [status, health, models, heartbeat] = await Promise.all([
|
||||
const [status, , models, heartbeat] = await Promise.all([
|
||||
state.client.request("status", {}),
|
||||
state.client.request("health", {}),
|
||||
state.client.request("models.list", {}),
|
||||
loadHealthState(state),
|
||||
loadModels(state.client),
|
||||
state.client.request("last-heartbeat", {}),
|
||||
]);
|
||||
state.debugStatus = status as StatusSummary;
|
||||
state.debugHealth = health as HealthSnapshot;
|
||||
const modelPayload = models as { models?: unknown[] } | undefined;
|
||||
state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : [];
|
||||
// Sync debugHealth from the shared healthResult for backward compat.
|
||||
state.debugHealth = state.healthResult;
|
||||
state.debugModels = models;
|
||||
state.debugHeartbeat = heartbeat;
|
||||
} catch (err) {
|
||||
state.debugCallError = String(err);
|
||||
|
||||
62
ui/src/ui/controllers/health.ts
Normal file
62
ui/src/ui/controllers/health.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { HealthSummary } from "../types.ts";
|
||||
|
||||
/** Default fallback returned when the gateway is unreachable or returns null. */
|
||||
const HEALTH_FALLBACK: HealthSummary = {
|
||||
ok: false,
|
||||
ts: 0,
|
||||
durationMs: 0,
|
||||
heartbeatSeconds: 0,
|
||||
defaultAgentId: "",
|
||||
agents: [],
|
||||
sessions: { path: "", count: 0, recent: [] },
|
||||
};
|
||||
|
||||
/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */
|
||||
export type HealthState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the gateway health summary.
|
||||
*
|
||||
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
|
||||
* convention). Returns a fully-typed {@link HealthSummary}; on failure the
|
||||
* caller receives a safe fallback with `ok: false` rather than `null`.
|
||||
*/
|
||||
export async function loadHealth(client: GatewayBrowserClient): Promise<HealthSummary> {
|
||||
try {
|
||||
const result = await client.request<HealthSummary>("health", {});
|
||||
return result ?? HEALTH_FALLBACK;
|
||||
} catch {
|
||||
return HEALTH_FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}).
|
||||
*
|
||||
* Populates `healthResult` / `healthError` on the provided state slice and
|
||||
* toggles `healthLoading` around the request.
|
||||
*/
|
||||
export async function loadHealthState(state: HealthState): Promise<void> {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.healthLoading) {
|
||||
return;
|
||||
}
|
||||
state.healthLoading = true;
|
||||
state.healthError = null;
|
||||
try {
|
||||
state.healthResult = await loadHealth(state.client);
|
||||
} catch (err) {
|
||||
state.healthError = String(err);
|
||||
} finally {
|
||||
state.healthLoading = false;
|
||||
}
|
||||
}
|
||||
18
ui/src/ui/controllers/models.ts
Normal file
18
ui/src/ui/controllers/models.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { ModelCatalogEntry } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Fetch the model catalog from the gateway.
|
||||
*
|
||||
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
|
||||
* convention). Returns an array of {@link ModelCatalogEntry}; on failure the
|
||||
* caller receives an empty array rather than throwing.
|
||||
*/
|
||||
export async function loadModels(client: GatewayBrowserClient): Promise<ModelCatalogEntry[]> {
|
||||
try {
|
||||
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
|
||||
return result?.models ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -58,3 +58,41 @@ export function parseList(input: string): string[] {
|
||||
export function stripThinkingTags(value: string): string {
|
||||
return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" });
|
||||
}
|
||||
|
||||
export function formatCost(cost: number | null | undefined, fallback = "$0.00"): string {
|
||||
if (cost == null || !Number.isFinite(cost)) {
|
||||
return fallback;
|
||||
}
|
||||
if (cost === 0) {
|
||||
return "$0.00";
|
||||
}
|
||||
if (cost < 0.01) {
|
||||
return `$${cost.toFixed(4)}`;
|
||||
}
|
||||
if (cost < 1) {
|
||||
return `$${cost.toFixed(3)}`;
|
||||
}
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatTokens(tokens: number | null | undefined, fallback = "0"): string {
|
||||
if (tokens == null || !Number.isFinite(tokens)) {
|
||||
return fallback;
|
||||
}
|
||||
if (tokens < 1000) {
|
||||
return String(Math.round(tokens));
|
||||
}
|
||||
if (tokens < 1_000_000) {
|
||||
const k = tokens / 1000;
|
||||
return k < 10 ? `${k.toFixed(1)}k` : `${Math.round(k)}k`;
|
||||
}
|
||||
const m = tokens / 1_000_000;
|
||||
return m < 10 ? `${m.toFixed(1)}M` : `${Math.round(m)}M`;
|
||||
}
|
||||
|
||||
export function formatPercent(value: number | null | undefined, fallback = "—"): string {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,6 @@ export class GatewayBrowserClient {
|
||||
const scopes = DEFAULT_OPERATOR_CONNECT_SCOPES;
|
||||
const role = "operator";
|
||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||
let canFallbackToShared = false;
|
||||
let authToken = this.opts.token;
|
||||
|
||||
if (isSecureContext) {
|
||||
@@ -165,7 +164,6 @@ export class GatewayBrowserClient {
|
||||
role,
|
||||
})?.token;
|
||||
authToken = storedToken ?? this.opts.token;
|
||||
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
}
|
||||
const auth =
|
||||
authToken || this.opts.password
|
||||
@@ -239,7 +237,11 @@ export class GatewayBrowserClient {
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
// Clear stale device token on any connect failure so the next attempt
|
||||
// falls back to the shared gateway token (if present) or retries without
|
||||
// a cached device token. Without this, a rotated/revoked device token
|
||||
// causes an infinite mismatch loop when no shared token is configured.
|
||||
if (deviceIdentity) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||
|
||||
@@ -228,6 +228,147 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
panelLeftClose: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" stroke-linecap="round" />
|
||||
<path d="M16 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`,
|
||||
panelLeftOpen: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" stroke-linecap="round" />
|
||||
<path d="M14 10l3 2-3 2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`,
|
||||
chevronDown: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 9l6 6 6-6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`,
|
||||
chevronRight: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M9 18l6-6-6-6" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`,
|
||||
externalLink: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M15 3h6v6M10 14L21 3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
`,
|
||||
send: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
`,
|
||||
stop: html`
|
||||
<svg viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1" /></svg>
|
||||
`,
|
||||
pin: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<line x1="12" x2="12" y1="17" y2="22" />
|
||||
<path
|
||||
d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
pinOff: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
<line x1="12" x2="12" y1="17" y2="22" />
|
||||
<path
|
||||
d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0-.39.04"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
download: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" x2="12" y1="15" y2="3" />
|
||||
</svg>
|
||||
`,
|
||||
mic: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
</svg>
|
||||
`,
|
||||
micOff: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<line x1="2" x2="22" y1="2" y2="22" />
|
||||
<path d="M18.89 13.23A7.12 7.12 0 0 0 19 12v-2" />
|
||||
<path d="M5 10v2a7 7 0 0 0 12 5" />
|
||||
<path d="M15 9.34V5a3 3 0 0 0-5.68-1.33" />
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12" />
|
||||
<line x1="12" x2="12" y1="19" y2="22" />
|
||||
</svg>
|
||||
`,
|
||||
bookmark: html`
|
||||
<svg viewBox="0 0 24 24"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /></svg>
|
||||
`,
|
||||
plus: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
`,
|
||||
terminal: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<polyline points="4 17 10 11 4 5" />
|
||||
<line x1="12" x2="20" y1="19" y2="19" />
|
||||
</svg>
|
||||
`,
|
||||
spark: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
refresh: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
`,
|
||||
trash: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
<line x1="10" x2="10" y1="11" y2="17" />
|
||||
<line x1="14" x2="14" y1="11" y2="17" />
|
||||
</svg>
|
||||
`,
|
||||
eye: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
`,
|
||||
eyeOff: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
|
||||
/>
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path
|
||||
d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
|
||||
/>
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
`,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -14,6 +14,7 @@ const allowedTags = [
|
||||
"br",
|
||||
"code",
|
||||
"del",
|
||||
"details",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
@@ -26,6 +27,7 @@ const allowedTags = [
|
||||
"p",
|
||||
"pre",
|
||||
"strong",
|
||||
"summary",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
@@ -132,6 +134,35 @@ export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||
const htmlEscapeRenderer = new marked.Renderer();
|
||||
htmlEscapeRenderer.html = ({ text }: { text: string }) => escapeHtml(text);
|
||||
|
||||
htmlEscapeRenderer.code = ({
|
||||
text,
|
||||
lang,
|
||||
escaped,
|
||||
}: {
|
||||
text: string;
|
||||
lang?: string;
|
||||
escaped: boolean;
|
||||
}) => {
|
||||
const langClass = lang ? ` class="language-${lang}"` : "";
|
||||
const safeText = escaped ? text : escapeHtml(text);
|
||||
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const isJson =
|
||||
lang === "json" ||
|
||||
(!lang &&
|
||||
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
|
||||
|
||||
if (isJson) {
|
||||
const lineCount = text.split("\n").length;
|
||||
const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON";
|
||||
return `<details class="json-collapse"><summary>${label}</summary>${codeBlock}</details>`;
|
||||
}
|
||||
|
||||
return codeBlock;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const KEY = "openclaw.control.settings.v1";
|
||||
|
||||
import { isSupportedLocale } from "../i18n/index.ts";
|
||||
import type { ThemeMode } from "./theme.ts";
|
||||
import { VALID_THEMES, type ThemeMode } from "./theme.ts";
|
||||
|
||||
export type UiSettings = {
|
||||
gatewayUrl: string;
|
||||
@@ -28,7 +28,7 @@ export function loadSettings(): UiSettings {
|
||||
token: "",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "system",
|
||||
theme: "dark",
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
@@ -57,10 +57,9 @@ export function loadSettings(): UiSettings {
|
||||
? parsed.lastActiveSessionKey.trim()
|
||||
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
|
||||
defaults.lastActiveSessionKey,
|
||||
theme:
|
||||
parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
|
||||
? parsed.theme
|
||||
: defaults.theme,
|
||||
theme: VALID_THEMES.has(parsed.theme as ThemeMode)
|
||||
? (parsed.theme as ThemeMode)
|
||||
: defaults.theme,
|
||||
chatFocusMode:
|
||||
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
|
||||
chatShowThinking:
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
export type ResolvedTheme = "light" | "dark";
|
||||
export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash";
|
||||
export type ResolvedTheme = ThemeMode;
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return "dark";
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
export const VALID_THEMES = new Set<ThemeMode>([
|
||||
"dark",
|
||||
"light",
|
||||
"openknot",
|
||||
"fieldmanual",
|
||||
"openai",
|
||||
"clawdash",
|
||||
]);
|
||||
|
||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode === "system") {
|
||||
return getSystemTheme();
|
||||
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 mode;
|
||||
return LEGACY_MAP[mode] ?? "dark";
|
||||
}
|
||||
|
||||
39
ui/src/ui/tool-labels.ts
Normal file
39
ui/src/ui/tool-labels.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Map raw tool names to human-friendly labels for the chat UI.
|
||||
* Unknown tools are title-cased with underscores replaced by spaces.
|
||||
*/
|
||||
|
||||
export const TOOL_LABELS: Record<string, string> = {
|
||||
exec: "Run Command",
|
||||
bash: "Run Command",
|
||||
read: "Read File",
|
||||
write: "Write File",
|
||||
edit: "Edit File",
|
||||
apply_patch: "Apply Patch",
|
||||
web_search: "Web Search",
|
||||
web_fetch: "Fetch Page",
|
||||
browser: "Browser",
|
||||
message: "Send Message",
|
||||
image: "Generate Image",
|
||||
canvas: "Canvas",
|
||||
cron: "Cron",
|
||||
gateway: "Gateway",
|
||||
nodes: "Nodes",
|
||||
memory_search: "Search Memory",
|
||||
memory_get: "Get Memory",
|
||||
session_status: "Session Status",
|
||||
sessions_list: "List Sessions",
|
||||
sessions_history: "Session History",
|
||||
sessions_send: "Send to Session",
|
||||
sessions_spawn: "Spawn Session",
|
||||
agents_list: "List Agents",
|
||||
};
|
||||
|
||||
export function friendlyToolName(raw: string): string {
|
||||
const mapped = TOOL_LABELS[raw];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
// Title-case fallback: "some_tool_name" → "Some Tool Name"
|
||||
return raw.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
@@ -556,6 +556,35 @@ export type StatusSummary = Record<string, unknown>;
|
||||
|
||||
export type HealthSnapshot = Record<string, unknown>;
|
||||
|
||||
/** Strongly-typed health response from the gateway (richer than HealthSnapshot). */
|
||||
export type HealthSummary = {
|
||||
ok: boolean;
|
||||
ts: number;
|
||||
durationMs: number;
|
||||
heartbeatSeconds: number;
|
||||
defaultAgentId: string;
|
||||
agents: Array<{ id: string; name?: string }>;
|
||||
sessions: {
|
||||
path: string;
|
||||
count: number;
|
||||
recent: Array<{
|
||||
key: string;
|
||||
updatedAt: number | null;
|
||||
age: number | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/** A model entry returned by the gateway model-catalog endpoint. */
|
||||
export type ModelCatalogEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
reasoning?: boolean;
|
||||
input?: Array<"text" | "image">;
|
||||
};
|
||||
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
export type LogEntry = {
|
||||
@@ -566,3 +595,16 @@ export type LogEntry = {
|
||||
message?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
// ── Attention ───────────────────────────────────────
|
||||
|
||||
export type AttentionSeverity = "error" | "warning" | "info";
|
||||
|
||||
export type AttentionItem = {
|
||||
severity: AttentionSeverity;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
href?: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
233
ui/src/ui/views/agents-panels-overview.ts
Normal file
233
ui/src/ui/views/agents-panels-overview.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
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)}
|
||||
>×</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>
|
||||
`;
|
||||
}
|
||||
@@ -230,7 +230,7 @@ export function renderAgentChannels(params: {
|
||||
const status = summary.total
|
||||
? `${summary.connected}/${summary.total} connected`
|
||||
: "no accounts";
|
||||
const config = summary.configured
|
||||
const configLabel = summary.configured
|
||||
? `${summary.configured} configured`
|
||||
: "not configured";
|
||||
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
|
||||
@@ -243,8 +243,23 @@ export function renderAgentChannels(params: {
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<div>${status}</div>
|
||||
<div>${config}</div>
|
||||
<div>${configLabel}</div>
|
||||
<div>${enabled}</div>
|
||||
${
|
||||
summary.configured === 0
|
||||
? html`
|
||||
<div>
|
||||
<a
|
||||
href="https://docs.openclaw.ai/channels"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: var(--accent); font-size: 12px"
|
||||
>Setup guide</a
|
||||
>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
extras.length > 0
|
||||
? extras.map(
|
||||
@@ -272,6 +287,7 @@ export function renderAgentCron(params: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onRunNow: (jobId: string) => void;
|
||||
}) {
|
||||
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
|
||||
return html`
|
||||
@@ -341,6 +357,12 @@ export function renderAgentCron(params: {
|
||||
<div class="list-meta">
|
||||
<div class="mono">${formatCronState(job)}</div>
|
||||
<div class="muted">${formatCronPayload(job)}</div>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
style="margin-top: 6px;"
|
||||
?disabled=${!job.enabled}
|
||||
@click=${() => params.onRunNow(job.id)}
|
||||
>Run Now</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -301,17 +301,27 @@ export function renderAgentSkills(params: {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
|
||||
Use All
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!editable}
|
||||
@click=${() => params.onDisableAll(params.agentId)}
|
||||
>
|
||||
Disable All
|
||||
</button>
|
||||
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
||||
<div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
|
||||
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!editable}
|
||||
@click=${() => params.onDisableAll(params.agentId)}
|
||||
>
|
||||
Disable All
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!editable || !usingAllowlist}
|
||||
@click=${() => params.onClear(params.agentId)}
|
||||
title="Remove per-agent allowlist and use all skills"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
|
||||
Reload Config
|
||||
</button>
|
||||
|
||||
@@ -189,6 +189,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) {
|
||||
return defaultId && agentId === defaultId ? "default" : null;
|
||||
}
|
||||
|
||||
export function agentAvatarHue(id: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
return ((hash % 360) + 360) % 360;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes?: number) {
|
||||
if (bytes == null || !Number.isFinite(bytes)) {
|
||||
return "-";
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
CronStatus,
|
||||
SkillStatusReport,
|
||||
} from "../types.ts";
|
||||
import { renderAgentOverview } from "./agents-panels-overview.ts";
|
||||
import {
|
||||
renderAgentFiles,
|
||||
renderAgentChannels,
|
||||
@@ -15,54 +16,70 @@ import {
|
||||
} from "./agents-panels-status-files.ts";
|
||||
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
||||
import {
|
||||
agentAvatarHue,
|
||||
agentBadgeText,
|
||||
buildAgentContext,
|
||||
buildModelOptions,
|
||||
normalizeAgentLabel,
|
||||
normalizeModelValue,
|
||||
parseFallbackList,
|
||||
resolveAgentConfig,
|
||||
resolveAgentEmoji,
|
||||
resolveModelFallbacks,
|
||||
resolveModelLabel,
|
||||
resolveModelPrimary,
|
||||
} from "./agents-utils.ts";
|
||||
|
||||
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
|
||||
export type ConfigState = {
|
||||
form: Record<string, unknown> | null;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
dirty: boolean;
|
||||
};
|
||||
|
||||
export type ChannelsState = {
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastSuccess: number | null;
|
||||
};
|
||||
|
||||
export type CronState = {
|
||||
status: CronStatus | null;
|
||||
jobs: CronJob[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type AgentFilesState = {
|
||||
list: AgentsFilesListResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
active: string | null;
|
||||
contents: Record<string, string>;
|
||||
drafts: Record<string, string>;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export type AgentSkillsState = {
|
||||
report: SkillStatusReport | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
agentId: string | null;
|
||||
filter: string;
|
||||
};
|
||||
|
||||
export type AgentsProps = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
selectedAgentId: string | null;
|
||||
activePanel: AgentsPanel;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configLoading: boolean;
|
||||
configSaving: boolean;
|
||||
configDirty: boolean;
|
||||
channelsLoading: boolean;
|
||||
channelsError: string | null;
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsLastSuccess: number | null;
|
||||
cronLoading: boolean;
|
||||
cronStatus: CronStatus | null;
|
||||
cronJobs: CronJob[];
|
||||
cronError: string | null;
|
||||
agentFilesLoading: boolean;
|
||||
agentFilesError: string | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
agentFileActive: string | null;
|
||||
agentFileContents: Record<string, string>;
|
||||
agentFileDrafts: Record<string, string>;
|
||||
agentFileSaving: boolean;
|
||||
config: ConfigState;
|
||||
channels: ChannelsState;
|
||||
cron: CronState;
|
||||
agentFiles: AgentFilesState;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
agentSkillsLoading: boolean;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
skillsFilter: string;
|
||||
agentSkills: AgentSkillsState;
|
||||
sidebarFilter: string;
|
||||
onSidebarFilterChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectPanel: (panel: AgentsPanel) => void;
|
||||
@@ -79,20 +96,13 @@ export type AgentsProps = {
|
||||
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
||||
onChannelsRefresh: () => void;
|
||||
onCronRefresh: () => void;
|
||||
onCronRunNow: (jobId: string) => void;
|
||||
onSkillsFilterChange: (next: string) => void;
|
||||
onSkillsRefresh: () => void;
|
||||
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
|
||||
onAgentSkillsClear: (agentId: string) => void;
|
||||
onAgentSkillsDisableAll: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export type AgentContext = {
|
||||
workspace: string;
|
||||
model: string;
|
||||
identityName: string;
|
||||
identityEmoji: string;
|
||||
skillsLabel: string;
|
||||
isDefault: boolean;
|
||||
onSetDefault: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function renderAgents(props: AgentsProps) {
|
||||
@@ -103,6 +113,27 @@ export function renderAgents(props: AgentsProps) {
|
||||
? (agents.find((agent) => agent.id === selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
const sidebarFilter = props.sidebarFilter.trim().toLowerCase();
|
||||
const filteredAgents = sidebarFilter
|
||||
? agents.filter((agent) => {
|
||||
const label = normalizeAgentLabel(agent).toLowerCase();
|
||||
return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter);
|
||||
})
|
||||
: agents;
|
||||
|
||||
const channelEntryCount = props.channels.snapshot
|
||||
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
|
||||
: null;
|
||||
const cronJobCount = selectedId
|
||||
? props.cron.jobs.filter((j) => j.agentId === selectedId).length
|
||||
: null;
|
||||
const tabCounts: Record<string, number | null> = {
|
||||
files: props.agentFiles.list?.files?.length ?? null,
|
||||
skills: props.agentSkills.report?.skills?.length ?? null,
|
||||
channels: channelEntryCount,
|
||||
cron: cronJobCount || null,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="agents-layout">
|
||||
<section class="card agents-sidebar">
|
||||
@@ -115,6 +146,21 @@ export function renderAgents(props: AgentsProps) {
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
agents.length > 1
|
||||
? html`
|
||||
<input
|
||||
class="field"
|
||||
type="text"
|
||||
placeholder="Filter agents…"
|
||||
.value=${props.sidebarFilter}
|
||||
@input=${(e: Event) =>
|
||||
props.onSidebarFilterChange((e.target as HTMLInputElement).value)}
|
||||
style="margin-top: 8px;"
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
@@ -122,20 +168,23 @@ export function renderAgents(props: AgentsProps) {
|
||||
}
|
||||
<div class="agent-list" style="margin-top: 12px;">
|
||||
${
|
||||
agents.length === 0
|
||||
filteredAgents.length === 0
|
||||
? html`
|
||||
<div class="muted">No agents found.</div>
|
||||
<div class="muted">${sidebarFilter ? "No matching agents." : "No agents found."}</div>
|
||||
`
|
||||
: agents.map((agent) => {
|
||||
: filteredAgents.map((agent) => {
|
||||
const badge = agentBadgeText(agent.id, defaultId);
|
||||
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
|
||||
const hue = agentAvatarHue(agent.id);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
class="agent-row ${selectedId === agent.id ? "active" : ""}"
|
||||
@click=${() => props.onSelectAgent(agent.id)}
|
||||
>
|
||||
<div class="agent-avatar">${emoji || normalizeAgentLabel(agent).slice(0, 1)}</div>
|
||||
<div class="agent-avatar" style="--agent-hue: ${hue}">
|
||||
${emoji || normalizeAgentLabel(agent).slice(0, 1)}
|
||||
</div>
|
||||
<div class="agent-info">
|
||||
<div class="agent-title">${normalizeAgentLabel(agent)}</div>
|
||||
<div class="agent-sub mono">${agent.id}</div>
|
||||
@@ -161,25 +210,27 @@ export function renderAgents(props: AgentsProps) {
|
||||
selectedAgent,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
props.onSetDefault,
|
||||
)}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
|
||||
${
|
||||
props.activePanel === "overview"
|
||||
? renderAgentOverview({
|
||||
agent: selectedAgent,
|
||||
defaultId,
|
||||
configForm: props.configForm,
|
||||
agentFilesList: props.agentFilesList,
|
||||
configForm: props.config.form,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
agentIdentityError: props.agentIdentityError,
|
||||
agentIdentityLoading: props.agentIdentityLoading,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
onConfigReload: props.onConfigReload,
|
||||
onConfigSave: props.onConfigSave,
|
||||
onModelChange: props.onModelChange,
|
||||
onModelFallbacksChange: props.onModelFallbacksChange,
|
||||
onSelectPanel: props.onSelectPanel,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -187,13 +238,13 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "files"
|
||||
? renderAgentFiles({
|
||||
agentId: selectedAgent.id,
|
||||
agentFilesList: props.agentFilesList,
|
||||
agentFilesLoading: props.agentFilesLoading,
|
||||
agentFilesError: props.agentFilesError,
|
||||
agentFileActive: props.agentFileActive,
|
||||
agentFileContents: props.agentFileContents,
|
||||
agentFileDrafts: props.agentFileDrafts,
|
||||
agentFileSaving: props.agentFileSaving,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
agentFilesLoading: props.agentFiles.loading,
|
||||
agentFilesError: props.agentFiles.error,
|
||||
agentFileActive: props.agentFiles.active,
|
||||
agentFileContents: props.agentFiles.contents,
|
||||
agentFileDrafts: props.agentFiles.drafts,
|
||||
agentFileSaving: props.agentFiles.saving,
|
||||
onLoadFiles: props.onLoadFiles,
|
||||
onSelectFile: props.onSelectFile,
|
||||
onFileDraftChange: props.onFileDraftChange,
|
||||
@@ -206,10 +257,10 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "tools"
|
||||
? renderAgentTools({
|
||||
agentId: selectedAgent.id,
|
||||
configForm: props.configForm,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
configForm: props.config.form,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
onProfileChange: props.onToolsProfileChange,
|
||||
onOverridesChange: props.onToolsOverridesChange,
|
||||
onConfigReload: props.onConfigReload,
|
||||
@@ -221,15 +272,15 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "skills"
|
||||
? renderAgentSkills({
|
||||
agentId: selectedAgent.id,
|
||||
report: props.agentSkillsReport,
|
||||
loading: props.agentSkillsLoading,
|
||||
error: props.agentSkillsError,
|
||||
activeAgentId: props.agentSkillsAgentId,
|
||||
configForm: props.configForm,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
filter: props.skillsFilter,
|
||||
report: props.agentSkills.report,
|
||||
loading: props.agentSkills.loading,
|
||||
error: props.agentSkills.error,
|
||||
activeAgentId: props.agentSkills.agentId,
|
||||
configForm: props.config.form,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
filter: props.agentSkills.filter,
|
||||
onFilterChange: props.onSkillsFilterChange,
|
||||
onRefresh: props.onSkillsRefresh,
|
||||
onToggle: props.onAgentSkillToggle,
|
||||
@@ -245,16 +296,16 @@ export function renderAgents(props: AgentsProps) {
|
||||
? renderAgentChannels({
|
||||
context: buildAgentContext(
|
||||
selectedAgent,
|
||||
props.configForm,
|
||||
props.agentFilesList,
|
||||
props.config.form,
|
||||
props.agentFiles.list,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
),
|
||||
configForm: props.configForm,
|
||||
snapshot: props.channelsSnapshot,
|
||||
loading: props.channelsLoading,
|
||||
error: props.channelsError,
|
||||
lastSuccess: props.channelsLastSuccess,
|
||||
configForm: props.config.form,
|
||||
snapshot: props.channels.snapshot,
|
||||
loading: props.channels.loading,
|
||||
error: props.channels.error,
|
||||
lastSuccess: props.channels.lastSuccess,
|
||||
onRefresh: props.onChannelsRefresh,
|
||||
})
|
||||
: nothing
|
||||
@@ -264,17 +315,18 @@ export function renderAgents(props: AgentsProps) {
|
||||
? renderAgentCron({
|
||||
context: buildAgentContext(
|
||||
selectedAgent,
|
||||
props.configForm,
|
||||
props.agentFilesList,
|
||||
props.config.form,
|
||||
props.agentFiles.list,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
),
|
||||
agentId: selectedAgent.id,
|
||||
jobs: props.cronJobs,
|
||||
status: props.cronStatus,
|
||||
loading: props.cronLoading,
|
||||
error: props.cronError,
|
||||
jobs: props.cron.jobs,
|
||||
status: props.cron.status,
|
||||
loading: props.cron.loading,
|
||||
error: props.cron.error,
|
||||
onRefresh: props.onCronRefresh,
|
||||
onRunNow: props.onCronRunNow,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -285,19 +337,32 @@ export function renderAgents(props: AgentsProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
let actionsMenuOpen = false;
|
||||
|
||||
function renderAgentHeader(
|
||||
agent: AgentsListResult["agents"][number],
|
||||
defaultId: string | null,
|
||||
agentIdentity: AgentIdentityResult | null,
|
||||
onSetDefault: (agentId: string) => void,
|
||||
) {
|
||||
const badge = agentBadgeText(agent.id, defaultId);
|
||||
const displayName = normalizeAgentLabel(agent);
|
||||
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
|
||||
const emoji = resolveAgentEmoji(agent, agentIdentity);
|
||||
const hue = agentAvatarHue(agent.id);
|
||||
const isDefault = Boolean(defaultId && agent.id === defaultId);
|
||||
|
||||
const copyId = () => {
|
||||
void navigator.clipboard.writeText(agent.id);
|
||||
actionsMenuOpen = false;
|
||||
};
|
||||
|
||||
return html`
|
||||
<section class="card agent-header">
|
||||
<div class="agent-header-main">
|
||||
<div class="agent-avatar agent-avatar--lg">${emoji || displayName.slice(0, 1)}</div>
|
||||
<div class="agent-avatar agent-avatar--lg" style="--agent-hue: ${hue}">
|
||||
${emoji || displayName.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-title">${displayName}</div>
|
||||
<div class="card-sub">${subtitle}</div>
|
||||
@@ -305,13 +370,47 @@ function renderAgentHeader(
|
||||
</div>
|
||||
<div class="agent-header-meta">
|
||||
<div class="mono">${agent.id}</div>
|
||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
||||
<div class="row" style="gap: 8px; align-items: center;">
|
||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
||||
<div class="agent-actions-wrap">
|
||||
<button
|
||||
class="agent-actions-toggle"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
actionsMenuOpen = !actionsMenuOpen;
|
||||
}}
|
||||
>⋯</button>
|
||||
${
|
||||
actionsMenuOpen
|
||||
? html`
|
||||
<div class="agent-actions-menu">
|
||||
<button type="button" @click=${copyId}>Copy agent ID</button>
|
||||
<button
|
||||
type="button"
|
||||
?disabled=${isDefault}
|
||||
@click=${() => {
|
||||
onSetDefault(agent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
${isDefault ? "Already default" : "Set as default"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) {
|
||||
function renderAgentTabs(
|
||||
active: AgentsPanel,
|
||||
onSelect: (panel: AgentsPanel) => void,
|
||||
counts: Record<string, number | null>,
|
||||
) {
|
||||
const tabs: Array<{ id: AgentsPanel; label: string }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "files", label: "Files" },
|
||||
@@ -329,161 +428,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) =>
|
||||
type="button"
|
||||
@click=${() => onSelect(tab.id)}
|
||||
>
|
||||
${tab.label}
|
||||
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAgentOverview(params: {
|
||||
agent: AgentsListResult["agents"][number];
|
||||
defaultId: string | null;
|
||||
configForm: Record<string, unknown> | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
agentIdentity: AgentIdentityResult | null;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
configLoading: boolean;
|
||||
configSaving: boolean;
|
||||
configDirty: boolean;
|
||||
onConfigReload: () => void;
|
||||
onConfigSave: () => void;
|
||||
onModelChange: (agentId: string, modelId: string | null) => void;
|
||||
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
||||
}) {
|
||||
const {
|
||||
agent,
|
||||
configForm,
|
||||
agentFilesList,
|
||||
agentIdentity,
|
||||
agentIdentityLoading,
|
||||
agentIdentityError,
|
||||
configLoading,
|
||||
configSaving,
|
||||
configDirty,
|
||||
onConfigReload,
|
||||
onConfigSave,
|
||||
onModelChange,
|
||||
onModelFallbacksChange,
|
||||
} = params;
|
||||
const config = resolveAgentConfig(configForm, agent.id);
|
||||
const workspaceFromFiles =
|
||||
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
|
||||
const workspace =
|
||||
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
|
||||
const model = config.entry?.model
|
||||
? resolveModelLabel(config.entry?.model)
|
||||
: resolveModelLabel(config.defaults?.model);
|
||||
const defaultModel = resolveModelLabel(config.defaults?.model);
|
||||
const modelPrimary =
|
||||
resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null);
|
||||
const defaultPrimary =
|
||||
resolveModelPrimary(config.defaults?.model) ||
|
||||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
|
||||
const effectivePrimary = modelPrimary ?? defaultPrimary ?? null;
|
||||
const modelFallbacks = 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>
|
||||
`;
|
||||
}
|
||||
|
||||
33
ui/src/ui/views/bottom-tabs.ts
Normal file
33
ui/src/ui/views/bottom-tabs.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { html } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { Tab } from "../navigation.ts";
|
||||
|
||||
export type BottomTabsProps = {
|
||||
activeTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
};
|
||||
|
||||
const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [
|
||||
{ id: "overview", label: "Dashboard", icon: "barChart" },
|
||||
{ id: "chat", label: "Chat", icon: "messageSquare" },
|
||||
{ id: "sessions", label: "Sessions", icon: "fileText" },
|
||||
{ id: "config", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export function renderBottomTabs(props: BottomTabsProps) {
|
||||
return html`
|
||||
<nav class="bottom-tabs">
|
||||
${BOTTOM_TABS.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
class="bottom-tab ${props.activeTab === tab.id ? "bottom-tab--active" : ""}"
|
||||
@click=${() => props.onTabChange(tab.id)}
|
||||
>
|
||||
<span class="bottom-tab__icon">${icons[tab.icon]}</span>
|
||||
<span class="bottom-tab__label">${tab.label}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
@@ -247,7 +247,7 @@ export function renderNostrProfileForm(params: {
|
||||
@click=${callbacks.onSave}
|
||||
?disabled=${state.saving || !isDirty}
|
||||
>
|
||||
${state.saving ? "Saving..." : "Save & Publish"}
|
||||
${state.saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -45,6 +45,9 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
onSend: () => undefined,
|
||||
onQueueRemove: () => undefined,
|
||||
onNewSession: () => undefined,
|
||||
agentsList: null,
|
||||
currentAgentId: "main",
|
||||
onAgentChange: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
244
ui/src/ui/views/command-palette.ts
Normal file
244
ui/src/ui/views/command-palette.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -118,12 +118,47 @@ function normalizeSchemaNode(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAllOf(schema: JsonSchema, path: Array<string | number>): ConfigSchemaAnalysis | null {
|
||||
const branches = schema.allOf;
|
||||
if (!branches || branches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const merged: JsonSchema = { ...schema, allOf: undefined };
|
||||
for (const branch of branches) {
|
||||
if (!branch || typeof branch !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (branch.type) {
|
||||
merged.type = merged.type ?? branch.type;
|
||||
}
|
||||
if (branch.properties) {
|
||||
merged.properties = { ...merged.properties, ...branch.properties };
|
||||
}
|
||||
if (branch.items && !merged.items) {
|
||||
merged.items = branch.items;
|
||||
}
|
||||
if (branch.enum) {
|
||||
merged.enum = branch.enum;
|
||||
}
|
||||
if (branch.description && !merged.description) {
|
||||
merged.description = branch.description;
|
||||
}
|
||||
if (branch.title && !merged.title) {
|
||||
merged.title = branch.title;
|
||||
}
|
||||
if (branch.default !== undefined && merged.default === undefined) {
|
||||
merged.default = branch.default;
|
||||
}
|
||||
}
|
||||
return normalizeSchemaNode(merged, path);
|
||||
}
|
||||
|
||||
function normalizeUnion(
|
||||
schema: JsonSchema,
|
||||
path: Array<string | number>,
|
||||
): ConfigSchemaAnalysis | null {
|
||||
if (schema.allOf) {
|
||||
return null;
|
||||
return mergeAllOf(schema, path);
|
||||
}
|
||||
const union = schema.anyOf ?? schema.oneOf;
|
||||
if (!union) {
|
||||
@@ -181,7 +216,7 @@ function normalizeUnion(
|
||||
};
|
||||
}
|
||||
|
||||
if (remaining.length === 1) {
|
||||
if (remaining.length === 1 && literals.length === 0) {
|
||||
const res = normalizeSchemaNode(remaining[0], path);
|
||||
if (res.schema) {
|
||||
res.schema.nullable = nullable || res.schema.nullable;
|
||||
@@ -189,6 +224,41 @@ function normalizeUnion(
|
||||
return res;
|
||||
}
|
||||
|
||||
// Literals + single typed remainder (e.g. boolean | enum["off","partial"]):
|
||||
// merge literals into an enum on the combined schema so segmented/select renders all options.
|
||||
if (remaining.length === 1 && literals.length > 0) {
|
||||
const remType = schemaType(remaining[0]);
|
||||
if (remType === "boolean") {
|
||||
const all = [true, false, ...literals];
|
||||
const unique: unknown[] = [];
|
||||
for (const v of all) {
|
||||
if (!unique.some((e) => Object.is(e, v))) {
|
||||
unique.push(v);
|
||||
}
|
||||
}
|
||||
return {
|
||||
schema: {
|
||||
...schema,
|
||||
enum: unique,
|
||||
nullable,
|
||||
anyOf: undefined,
|
||||
oneOf: undefined,
|
||||
allOf: undefined,
|
||||
},
|
||||
unsupportedPaths: [],
|
||||
};
|
||||
}
|
||||
// Single remaining primitive — pass through as-is so the renderer picks the right widget
|
||||
const primitiveTypes = new Set(["string", "number", "integer"]);
|
||||
if (remType && primitiveTypes.has(remType)) {
|
||||
const res = normalizeSchemaNode(remaining[0], path);
|
||||
if (res.schema) {
|
||||
res.schema.nullable = nullable || res.schema.nullable;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const primitiveTypes = new Set(["string", "number", "integer", "boolean"]);
|
||||
if (
|
||||
remaining.length > 0 &&
|
||||
@@ -204,5 +274,9 @@ function normalizeUnion(
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fallback: pass the schema through and let the renderer show a JSON textarea
|
||||
return {
|
||||
schema: { ...schema, nullable },
|
||||
unsupportedPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,44 @@ function jsonValue(value: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function renderJsonFallback(params: {
|
||||
label: string;
|
||||
help: string | undefined;
|
||||
value: unknown;
|
||||
path: Array<string | number>;
|
||||
disabled: boolean;
|
||||
showLabel: boolean;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const { label, help, value, path, disabled, showLabel, onPatch } = params;
|
||||
const display = jsonValue(value);
|
||||
return html`
|
||||
<div class="cfg-field">
|
||||
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||
<textarea
|
||||
class="cfg-textarea"
|
||||
rows=${Math.min(Math.max((display.match(/\n/g)?.length ?? 0) + 1, 2), 10)}
|
||||
placeholder="JSON value"
|
||||
.value=${display}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) => {
|
||||
const raw = (e.target as HTMLTextAreaElement).value.trim();
|
||||
if (!raw) {
|
||||
onPatch(path, undefined);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onPatch(path, JSON.parse(raw));
|
||||
} catch {
|
||||
// leave as-is if invalid JSON
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// SVG Icons as template literals
|
||||
const icons = {
|
||||
chevronDown: html`
|
||||
@@ -113,10 +151,7 @@ export function renderNode(params: {
|
||||
const key = pathKey(path);
|
||||
|
||||
if (unsupported.has(key)) {
|
||||
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>`;
|
||||
return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel });
|
||||
}
|
||||
|
||||
// Handle anyOf/oneOf unions
|
||||
@@ -282,13 +317,8 @@ export function renderNode(params: {
|
||||
return renderTextInput({ ...params, inputType: "text" });
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return html`
|
||||
<div class="cfg-field cfg-field--error">
|
||||
<div class="cfg-field__label">${label}</div>
|
||||
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
|
||||
</div>
|
||||
`;
|
||||
// Fallback — render a JSON textarea for types the form renderer doesn't know about
|
||||
return renderJsonFallback({ label, help, value, path, disabled, onPatch, showLabel });
|
||||
}
|
||||
|
||||
function renderTextInput(params: {
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("config view", () => {
|
||||
searchQuery: "",
|
||||
activeSection: null,
|
||||
activeSubsection: null,
|
||||
streamMode: false,
|
||||
onRawChange: vi.fn(),
|
||||
onFormModeChange: vi.fn(),
|
||||
onFormPatch: vi.fn(),
|
||||
@@ -37,7 +38,7 @@ describe("config view", () => {
|
||||
onSubsectionChange: vi.fn(),
|
||||
});
|
||||
|
||||
it("allows save when form is unsafe", () => {
|
||||
it("allows save with mixed union schemas", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderConfig({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
|
||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
|
||||
@@ -22,6 +23,7 @@ export type ConfigProps = {
|
||||
searchQuery: string;
|
||||
activeSection: string | null;
|
||||
activeSubsection: string | null;
|
||||
streamMode: boolean;
|
||||
onRawChange: (next: string) => void;
|
||||
onFormModeChange: (mode: "form" | "raw") => void;
|
||||
onFormPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
@@ -383,6 +385,44 @@ function truncateValue(value: unknown, maxLen = 40): string {
|
||||
return str.slice(0, maxLen - 3) + "...";
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_RE = /token|password|secret|api.?key/i;
|
||||
const SENSITIVE_KEY_WHITELIST_RE =
|
||||
/maxtokens|maxoutputtokens|maxinputtokens|maxcompletiontokens|contexttokens|totaltokens|tokencount|tokenlimit|tokenbudget|passwordfile/i;
|
||||
|
||||
function countSensitiveValues(formValue: Record<string, unknown> | null): number {
|
||||
if (!formValue) {
|
||||
return 0;
|
||||
}
|
||||
let count = 0;
|
||||
function walk(obj: unknown, key?: string) {
|
||||
if (obj == null) {
|
||||
return;
|
||||
}
|
||||
if (typeof obj === "object" && !Array.isArray(obj)) {
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
walk(v, k);
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
walk(item);
|
||||
}
|
||||
} else if (
|
||||
key &&
|
||||
typeof obj === "string" &&
|
||||
SENSITIVE_KEY_RE.test(key) &&
|
||||
!SENSITIVE_KEY_WHITELIST_RE.test(key)
|
||||
) {
|
||||
if (obj.trim() && !/^\$\{[^}]*\}$/.test(obj.trim())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(formValue);
|
||||
return count;
|
||||
}
|
||||
|
||||
let rawRevealed = false;
|
||||
|
||||
export function renderConfig(props: ConfigProps) {
|
||||
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
|
||||
const analysis = analyzeConfigSchema(props.schema);
|
||||
@@ -649,6 +689,32 @@ export function renderConfig(props: ConfigProps) {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
props.activeSection === "env"
|
||||
? html`
|
||||
<button
|
||||
class="config-env-peek-btn"
|
||||
title="Toggle value visibility"
|
||||
@click=${(e: Event) => {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const content = btn
|
||||
.closest(".config-main")
|
||||
?.querySelector(".config-content");
|
||||
if (content) {
|
||||
content.classList.toggle("config-env-values--visible");
|
||||
}
|
||||
btn.classList.toggle("config-env-peek-btn--active");
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
Peek
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
@@ -682,7 +748,7 @@ export function renderConfig(props: ConfigProps) {
|
||||
}
|
||||
|
||||
<!-- Form content -->
|
||||
<div class="config-content">
|
||||
<div class="config-content ${props.activeSection === "env" ? "config-env-values--blurred" : ""}">
|
||||
${
|
||||
props.formMode === "form"
|
||||
? html`
|
||||
@@ -716,16 +782,43 @@ export function renderConfig(props: ConfigProps) {
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<label class="field config-raw-field">
|
||||
<span>Raw JSON5</span>
|
||||
<textarea
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
`
|
||||
: (() => {
|
||||
const sensitiveCount = countSensitiveValues(props.formValue);
|
||||
const blurred = sensitiveCount > 0 && (props.streamMode || !rawRevealed);
|
||||
return html`
|
||||
<label class="field config-raw-field">
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
Raw JSON5
|
||||
${
|
||||
sensitiveCount > 0
|
||||
? html`
|
||||
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
|
||||
<button
|
||||
class="btn btn--icon ${blurred ? "" : "active"}"
|
||||
style="width:28px;height:28px;padding:0;"
|
||||
title=${blurred ? "Reveal sensitive values" : "Hide sensitive values"}
|
||||
aria-label="Toggle raw config redaction"
|
||||
aria-pressed=${!blurred}
|
||||
@click=${() => {
|
||||
rawRevealed = !rawRevealed;
|
||||
props.onRawChange(props.raw);
|
||||
}}
|
||||
>
|
||||
${blurred ? icons.eyeOff : icons.eye}
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
<textarea
|
||||
class="${blurred ? "config-raw-redacted" : ""}"
|
||||
.value=${props.raw}
|
||||
@input=${(e: Event) =>
|
||||
props.onRawChange((e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
</label>
|
||||
`;
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ export function renderCron(props: CronProps) {
|
||||
<div class="muted" style="margin-top: 12px">No runs yet.</div>
|
||||
`
|
||||
: html`
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
<div class="list list-scroll" style="margin-top: 12px;">
|
||||
${orderedRuns.map((entry) => renderRun(entry, props.basePath))}
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { EventLogEntry } from "../app-events.ts";
|
||||
import { formatEventPayload } from "../presenter.ts";
|
||||
import type { HealthSummary, ModelCatalogEntry } from "../types.ts";
|
||||
|
||||
export type DebugProps = {
|
||||
loading: boolean;
|
||||
status: Record<string, unknown> | null;
|
||||
health: Record<string, unknown> | null;
|
||||
models: unknown[];
|
||||
health: HealthSummary | null;
|
||||
models: ModelCatalogEntry[];
|
||||
heartbeat: unknown;
|
||||
eventLog: EventLogEntry[];
|
||||
callMethod: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { formatPresenceAge } from "../presenter.ts";
|
||||
import type { PresenceEntry } from "../types.ts";
|
||||
|
||||
export type InstancesProps = {
|
||||
@@ -7,10 +8,15 @@ export type InstancesProps = {
|
||||
entries: PresenceEntry[];
|
||||
lastError: string | null;
|
||||
statusMessage: string | null;
|
||||
streamMode: boolean;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
let hostsRevealed = false;
|
||||
|
||||
export function renderInstances(props: InstancesProps) {
|
||||
const masked = props.streamMode || !hostsRevealed;
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
@@ -18,9 +24,24 @@ export function renderInstances(props: InstancesProps) {
|
||||
<div class="card-title">Connected Instances</div>
|
||||
<div class="card-sub">Presence beacons from the gateway and clients.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<button
|
||||
class="btn btn--icon ${masked ? "" : "active"}"
|
||||
@click=${() => {
|
||||
hostsRevealed = !hostsRevealed;
|
||||
props.onRefresh();
|
||||
}}
|
||||
title=${masked ? "Show hosts and IPs" : "Hide hosts and IPs"}
|
||||
aria-label="Toggle host visibility"
|
||||
aria-pressed=${!masked}
|
||||
style="width: 36px; height: 36px;"
|
||||
>
|
||||
${masked ? icons.eyeOff : icons.eye}
|
||||
</button>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
props.lastError
|
||||
@@ -42,16 +63,18 @@ export function renderInstances(props: InstancesProps) {
|
||||
? html`
|
||||
<div class="muted">No instances reported yet.</div>
|
||||
`
|
||||
: props.entries.map((entry) => renderEntry(entry))
|
||||
: props.entries.map((entry) => renderEntry(entry, masked))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEntry(entry: PresenceEntry) {
|
||||
function renderEntry(entry: PresenceEntry, masked: boolean) {
|
||||
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
|
||||
const mode = entry.mode ?? "unknown";
|
||||
const host = entry.host ?? "unknown host";
|
||||
const ip = entry.ip ?? null;
|
||||
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
|
||||
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
|
||||
const scopesLabel =
|
||||
@@ -63,8 +86,12 @@ function renderEntry(entry: PresenceEntry) {
|
||||
return html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${entry.host ?? "unknown host"}</div>
|
||||
<div class="list-sub">${formatPresenceSummary(entry)}</div>
|
||||
<div class="list-title">
|
||||
<span class="${masked ? "redacted" : ""}">${host}</span>
|
||||
</div>
|
||||
<div class="list-sub">
|
||||
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip">${mode}</span>
|
||||
${roles.map((role) => html`<span class="chip">${role}</span>`)}
|
||||
|
||||
86
ui/src/ui/views/login-gate.ts
Normal file
86
ui/src/ui/views/login-gate.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
60
ui/src/ui/views/overview-attention.ts
Normal file
60
ui/src/ui/views/overview-attention.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons, type IconName } from "../icons.ts";
|
||||
import type { AttentionItem } from "../types.ts";
|
||||
|
||||
export type OverviewAttentionProps = {
|
||||
items: AttentionItem[];
|
||||
};
|
||||
|
||||
function severityClass(severity: string) {
|
||||
if (severity === "error") {
|
||||
return "danger";
|
||||
}
|
||||
if (severity === "warning") {
|
||||
return "warn";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function attentionIcon(name: string) {
|
||||
if (name in icons) {
|
||||
return icons[name as IconName];
|
||||
}
|
||||
return icons.radio;
|
||||
}
|
||||
|
||||
export function renderOverviewAttention(props: OverviewAttentionProps) {
|
||||
if (props.items.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<section class="card ov-attention">
|
||||
<div class="card-title">${t("overview.attention.title")}</div>
|
||||
<div class="ov-attention-list">
|
||||
${props.items.map(
|
||||
(item) => html`
|
||||
<div class="ov-attention-item ${severityClass(item.severity)}">
|
||||
<span class="ov-attention-icon">${attentionIcon(item.icon)}</span>
|
||||
<div class="ov-attention-body">
|
||||
<div class="ov-attention-title">${item.title}</div>
|
||||
<div class="muted">${item.description}</div>
|
||||
</div>
|
||||
${
|
||||
item.href
|
||||
? html`<a
|
||||
class="ov-attention-link"
|
||||
href=${item.href}
|
||||
target=${item.external ? "_blank" : ""}
|
||||
rel=${item.external ? "noreferrer" : ""}
|
||||
>${t("common.docs")}</a>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
129
ui/src/ui/views/overview-cards.ts
Normal file
129
ui/src/ui/views/overview-cards.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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
|
||||
}
|
||||
`;
|
||||
}
|
||||
43
ui/src/ui/views/overview-event-log.ts
Normal file
43
ui/src/ui/views/overview-event-log.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { EventLogEntry } from "../app-events.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { formatEventPayload } from "../presenter.ts";
|
||||
|
||||
export type OverviewEventLogProps = {
|
||||
events: EventLogEntry[];
|
||||
redacted: boolean;
|
||||
};
|
||||
|
||||
export function renderOverviewEventLog(props: OverviewEventLogProps) {
|
||||
if (props.events.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const visible = props.events.slice(0, 20);
|
||||
|
||||
return html`
|
||||
<details class="card ov-event-log">
|
||||
<summary class="ov-expandable-toggle">
|
||||
<span class="nav-item__icon">${icons.radio}</span>
|
||||
${t("overview.eventLog.title")}
|
||||
<span class="ov-count-badge">${props.events.length}</span>
|
||||
</summary>
|
||||
<div class="ov-event-log-list ${props.redacted ? "redacted" : ""}">
|
||||
${visible.map(
|
||||
(entry) => html`
|
||||
<div class="ov-event-log-entry">
|
||||
<span class="ov-event-log-ts">${new Date(entry.ts).toLocaleTimeString()}</span>
|
||||
<span class="ov-event-log-name">${entry.event}</span>
|
||||
${
|
||||
entry.payload
|
||||
? html`<span class="ov-event-log-payload muted">${formatEventPayload(entry.payload).slice(0, 120)}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
36
ui/src/ui/views/overview-log-tail.ts
Normal file
36
ui/src/ui/views/overview-log-tail.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
31
ui/src/ui/views/overview-quick-actions.ts
Normal file
31
ui/src/ui/views/overview-quick-actions.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { html } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
|
||||
export type OverviewQuickActionsProps = {
|
||||
onNavigate: (tab: string) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function renderOverviewQuickActions(props: OverviewQuickActionsProps) {
|
||||
return html`
|
||||
<section class="ov-quick-actions">
|
||||
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("chat")}>
|
||||
<span class="nav-item__icon">${icons.messageSquare}</span>
|
||||
${t("overview.quickActions.newSession")}
|
||||
</button>
|
||||
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("cron")}>
|
||||
<span class="nav-item__icon">${icons.zap}</span>
|
||||
${t("overview.quickActions.automation")}
|
||||
</button>
|
||||
<button class="btn ov-quick-action-btn" @click=${() => props.onRefresh()}>
|
||||
<span class="nav-item__icon">${icons.loader}</span>
|
||||
${t("overview.quickActions.refreshAll")}
|
||||
</button>
|
||||
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("sessions")}>
|
||||
<span class="nav-item__icon">${icons.monitor}</span>
|
||||
${t("overview.quickActions.terminal")}
|
||||
</button>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -1,9 +1,22 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { t, i18n, type Locale } from "../../i18n/index.ts";
|
||||
import type { EventLogEntry } from "../app-events.ts";
|
||||
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||
import type { GatewayHelloOk } from "../gateway.ts";
|
||||
import { formatNextRun } from "../presenter.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import type { UiSettings } from "../storage.ts";
|
||||
import type {
|
||||
AttentionItem,
|
||||
CronJob,
|
||||
CronStatus,
|
||||
SessionsListResult,
|
||||
SessionsUsageResult,
|
||||
SkillStatusReport,
|
||||
} from "../types.ts";
|
||||
import { renderOverviewAttention } from "./overview-attention.ts";
|
||||
import { renderOverviewCards } from "./overview-cards.ts";
|
||||
import { renderOverviewEventLog } from "./overview-event-log.ts";
|
||||
import { renderOverviewLogTail } from "./overview-log-tail.ts";
|
||||
|
||||
export type OverviewProps = {
|
||||
connected: boolean;
|
||||
@@ -16,11 +29,24 @@ export type OverviewProps = {
|
||||
cronEnabled: boolean | null;
|
||||
cronNext: number | null;
|
||||
lastChannelsRefresh: number | null;
|
||||
// New dashboard data
|
||||
usageResult: SessionsUsageResult | null;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
skillsReport: SkillStatusReport | null;
|
||||
cronJobs: CronJob[];
|
||||
cronStatus: CronStatus | null;
|
||||
attentionItems: AttentionItem[];
|
||||
eventLog: EventLogEntry[];
|
||||
overviewLogLines: string[];
|
||||
streamMode: boolean;
|
||||
onSettingsChange: (next: UiSettings) => void;
|
||||
onPasswordChange: (next: string) => void;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
onConnect: () => void;
|
||||
onRefresh: () => void;
|
||||
onNavigate: (tab: string) => void;
|
||||
onRefreshLogs: () => void;
|
||||
onToggleStreamMode: () => void;
|
||||
};
|
||||
|
||||
export function renderOverview(props: OverviewProps) {
|
||||
@@ -33,7 +59,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
| undefined;
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
|
||||
const tick = snapshot?.policy?.tickIntervalMs
|
||||
? `${snapshot.policy.tickIntervalMs}ms`
|
||||
? `${(snapshot.policy.tickIntervalMs / 1000).toFixed(snapshot.policy.tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
|
||||
: t("common.na");
|
||||
const authMode = snapshot?.authMode;
|
||||
const isTrustedProxy = authMode === "trusted-proxy";
|
||||
@@ -135,7 +161,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
<div class="card">
|
||||
<div class="card-title">${t("overview.access.title")}</div>
|
||||
<div class="card-sub">${t("overview.access.subtitle")}</div>
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<div class="form-grid ${props.streamMode ? "redacted" : ""}" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<span>${t("overview.access.wsUrl")}</span>
|
||||
<input
|
||||
@@ -154,6 +180,8 @@ export function renderOverview(props: OverviewProps) {
|
||||
<label class="field">
|
||||
<span>${t("overview.access.token")}</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
@@ -210,6 +238,36 @@ export function renderOverview(props: OverviewProps) {
|
||||
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
|
||||
}</span>
|
||||
</div>
|
||||
${
|
||||
!props.connected
|
||||
? html`
|
||||
<div style="margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px;">
|
||||
<div style="font-weight: 600; font-size: 13px; margin-bottom: 10px;">${t("overview.connection.title")}</div>
|
||||
<ol class="muted" style="margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.8;">
|
||||
<li>${t("overview.connection.step1")}
|
||||
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw gateway run</div>
|
||||
</li>
|
||||
<li>${t("overview.connection.step2")}
|
||||
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw dashboard --no-open</div>
|
||||
</li>
|
||||
<li>${t("overview.connection.step3")}</li>
|
||||
<li>${t("overview.connection.step4")}
|
||||
<div class="mono" style="font-size: 12px; margin: 4px 0 6px;">openclaw doctor --generate-gateway-token</div>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="muted" style="font-size: 12px; margin-top: 10px;">
|
||||
${t("overview.connection.docsHint")}
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.openclaw.ai/web/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${t("overview.connection.docsLink")}</a>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -253,45 +311,43 @@ export function renderOverview(props: OverviewProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-3" style="margin-top: 18px;">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.instances")}</div>
|
||||
<div class="stat-value">${props.presenceCount}</div>
|
||||
<div class="muted">${t("overview.stats.instancesHint")}</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.sessions")}</div>
|
||||
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
|
||||
<div class="muted">${t("overview.stats.sessionsHint")}</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.cron")}</div>
|
||||
<div class="stat-value">
|
||||
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
|
||||
</div>
|
||||
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
|
||||
</div>
|
||||
</section>
|
||||
${
|
||||
props.streamMode
|
||||
? html`<div class="callout ov-stream-banner" style="margin-top: 18px;">
|
||||
<span class="nav-item__icon">${icons.radio}</span>
|
||||
${t("overview.streamMode.active")}
|
||||
<button class="btn btn--sm" style="margin-left: auto;" @click=${() => props.onToggleStreamMode()}>
|
||||
${t("overview.streamMode.disable")}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
${renderOverviewCards({
|
||||
usageResult: props.usageResult,
|
||||
sessionsResult: props.sessionsResult,
|
||||
skillsReport: props.skillsReport,
|
||||
cronJobs: props.cronJobs,
|
||||
cronStatus: props.cronStatus,
|
||||
presenceCount: props.presenceCount,
|
||||
redacted: props.streamMode,
|
||||
onNavigate: props.onNavigate,
|
||||
})}
|
||||
|
||||
${renderOverviewAttention({ items: props.attentionItems })}
|
||||
|
||||
<div class="ov-bottom-grid" style="margin-top: 18px;">
|
||||
${renderOverviewEventLog({
|
||||
events: props.eventLog,
|
||||
redacted: props.streamMode,
|
||||
})}
|
||||
|
||||
${renderOverviewLogTail({
|
||||
lines: props.overviewLogLines,
|
||||
redacted: props.streamMode,
|
||||
onRefreshLogs: props.onRefreshLogs,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section class="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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -54,16 +54,16 @@ export const usageStylesPart1 = `
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 77, 77, 0.1);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #ff4d4d;
|
||||
color: var(--accent);
|
||||
}
|
||||
.usage-refresh-indicator::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid #ff4d4d;
|
||||
border: 2px solid var(--accent);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: usage-spin 0.6s linear infinite;
|
||||
@@ -161,36 +161,36 @@ export const usageStylesPart1 = `
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.usage-primary-btn {
|
||||
background: #ff4d4d;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: #ff4d4d;
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.btn.usage-primary-btn {
|
||||
background: #ff4d4d !important;
|
||||
border-color: #ff4d4d !important;
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.usage-primary-btn:hover {
|
||||
background: #e64545;
|
||||
border-color: #e64545;
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
.btn.usage-primary-btn:hover {
|
||||
background: #e64545 !important;
|
||||
border-color: #e64545 !important;
|
||||
background: var(--accent-strong) !important;
|
||||
border-color: var(--accent-strong) !important;
|
||||
}
|
||||
.usage-primary-btn:disabled {
|
||||
background: rgba(255, 77, 77, 0.18);
|
||||
border-color: rgba(255, 77, 77, 0.3);
|
||||
color: #ff4d4d;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
color: var(--accent);
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
opacity: 1;
|
||||
}
|
||||
.usage-primary-btn[disabled] {
|
||||
background: rgba(255, 77, 77, 0.18) !important;
|
||||
border-color: rgba(255, 77, 77, 0.3) !important;
|
||||
color: #ff4d4d !important;
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent) !important;
|
||||
color: var(--accent) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.usage-secondary-btn {
|
||||
@@ -533,8 +533,8 @@ export const usageStylesPart1 = `
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
color: var(--text);
|
||||
background: rgba(255, 77, 77, 0.08);
|
||||
border: 1px solid rgba(255, 77, 77, 0.2);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
@@ -554,14 +554,14 @@ export const usageStylesPart1 = `
|
||||
.usage-hour-cell {
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 77, 77, 0.1);
|
||||
border: 1px solid rgba(255, 77, 77, 0.2);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.usage-hour-cell.selected {
|
||||
border-color: rgba(255, 77, 77, 0.8);
|
||||
box-shadow: 0 0 0 2px rgba(255, 77, 77, 0.2);
|
||||
border-color: color-mix(in srgb, var(--accent) 80%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.usage-hour-labels {
|
||||
display: grid;
|
||||
@@ -584,8 +584,8 @@ export const usageStylesPart1 = `
|
||||
width: 14px;
|
||||
height: 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 77, 77, 0.15);
|
||||
border: 1px solid rgba(255, 77, 77, 0.2);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
.usage-calendar-labels {
|
||||
display: grid;
|
||||
@@ -603,8 +603,8 @@ export const usageStylesPart1 = `
|
||||
.usage-calendar-cell {
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 77, 77, 0.2);
|
||||
background: rgba(255, 77, 77, 0.08);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
.usage-calendar-cell.empty {
|
||||
background: transparent;
|
||||
|
||||
@@ -100,7 +100,7 @@ export const usageStylesPart2 = `
|
||||
color: var(--text);
|
||||
}
|
||||
.chart-toggle .toggle-btn.active {
|
||||
background: #ff4d4d;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.chart-toggle.small .toggle-btn {
|
||||
@@ -157,14 +157,14 @@ export const usageStylesPart2 = `
|
||||
.daily-bar {
|
||||
width: 100%;
|
||||
max-width: var(--bar-max-width, 32px);
|
||||
background: #ff4d4d;
|
||||
background: var(--accent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: all 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.daily-bar-wrapper:hover .daily-bar {
|
||||
background: #cc3d3d;
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
.daily-bar-label {
|
||||
position: absolute;
|
||||
@@ -282,7 +282,7 @@ export const usageStylesPart2 = `
|
||||
background: #06b6d4;
|
||||
}
|
||||
.legend-dot.system {
|
||||
background: #ff4d4d;
|
||||
background: var(--accent);
|
||||
}
|
||||
.legend-dot.skills {
|
||||
background: #8b5cf6;
|
||||
@@ -360,7 +360,7 @@ export const usageStylesPart2 = `
|
||||
}
|
||||
.session-bar-fill {
|
||||
height: 100%;
|
||||
background: rgba(255, 77, 77, 0.7);
|
||||
background: color-mix(in srgb, var(--accent) 70%, transparent);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -431,27 +431,27 @@ export const usageStylesPart2 = `
|
||||
fill: var(--muted);
|
||||
}
|
||||
.timeseries-svg .ts-area {
|
||||
fill: #ff4d4d;
|
||||
fill: var(--accent);
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
.timeseries-svg .ts-line {
|
||||
fill: none;
|
||||
stroke: #ff4d4d;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.timeseries-svg .ts-dot {
|
||||
fill: #ff4d4d;
|
||||
fill: var(--accent);
|
||||
transition: r 0.15s, fill 0.15s;
|
||||
}
|
||||
.timeseries-svg .ts-dot:hover {
|
||||
r: 5;
|
||||
}
|
||||
.timeseries-svg .ts-bar {
|
||||
fill: #ff4d4d;
|
||||
fill: var(--accent);
|
||||
transition: fill 0.15s;
|
||||
}
|
||||
.timeseries-svg .ts-bar:hover {
|
||||
fill: #cc3d3d;
|
||||
fill: var(--accent-strong);
|
||||
}
|
||||
.timeseries-svg .ts-bar.output { fill: #ef4444; }
|
||||
.timeseries-svg .ts-bar.input { fill: #f59e0b; }
|
||||
@@ -582,7 +582,7 @@ export const usageStylesPart2 = `
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.context-segment.system {
|
||||
background: #ff4d4d;
|
||||
background: var(--accent);
|
||||
}
|
||||
.context-segment.skills {
|
||||
background: #8b5cf6;
|
||||
|
||||
@@ -121,7 +121,7 @@ export const usageStylesPart3 = `
|
||||
.sessions-card .session-bar-row.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 77, 77, 0.15);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
.sessions-card .session-bar-label {
|
||||
flex: 1 1 auto;
|
||||
@@ -139,7 +139,7 @@ export const usageStylesPart3 = `
|
||||
opacity: 0.5;
|
||||
}
|
||||
.sessions-card .session-bar-fill {
|
||||
background: rgba(255, 77, 77, 0.55);
|
||||
background: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.sessions-clear-btn {
|
||||
margin-left: auto;
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineConfig(() => {
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user