mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
revert(ui): remove recent UI dashboard/theme commits from main
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
144
ui/CHECKLIST.md
144
ui/CHECKLIST.md
@@ -1,144 +0,0 @@
|
||||
# UI Dashboard — Verification Checklist
|
||||
|
||||
Run through this checklist after every change that touches `ui/` files.
|
||||
Open the dashboard at `http://localhost:<port>` (or the gateway's configured UI URL).
|
||||
|
||||
## Login & Shell
|
||||
|
||||
- [ ] Login gate renders when not authenticated
|
||||
- [ ] Login with valid password grants access
|
||||
- [ ] Login with invalid password shows error
|
||||
- [ ] App shell loads: sidebar, header, content area visible
|
||||
- [ ] Sidebar shows all tab groups: Chat, Control, Agent, Settings
|
||||
- [ ] Sidebar collapse/expand works; favicon logo shows when collapsed
|
||||
- [ ] Router: clicking each sidebar tab navigates and updates URL
|
||||
- [ ] Browser back/forward navigates between tabs
|
||||
- [ ] Direct URL navigation (e.g. `/chat`, `/overview`) loads correct tab
|
||||
|
||||
## Themes
|
||||
|
||||
- [ ] Theme switcher cycles through all 5 themes:
|
||||
- [ ] Dark (Obsidian)
|
||||
- [ ] Light
|
||||
- [ ] OpenKnot (Aurora)
|
||||
- [ ] Field Manual
|
||||
- [ ] OpenClaw (Chrome)
|
||||
- [ ] Glass components (cards, panels, inputs) render correctly per theme
|
||||
- [ ] Theme persists across page reload
|
||||
|
||||
## Overview
|
||||
|
||||
- [ ] Overview tab loads without errors
|
||||
- [ ] Stat cards render: cost, sessions, skills, cron
|
||||
- [ ] Cards show accent color borders per kind
|
||||
- [ ] Cards show hover lift + shadow effect
|
||||
- [ ] Cards are clickable and navigate to corresponding tab
|
||||
- [ ] Responsive grid: 4 columns → 2 → 1 at breakpoints
|
||||
- [ ] Attention items render with correct severity icons/colors (error, warning, info)
|
||||
- [ ] Event log renders with timestamps
|
||||
- [ ] Log tail section renders live gateway log lines
|
||||
- [ ] Quick actions section renders
|
||||
- [ ] Redact toggle in topbar redacts/reveals sensitive values in cards
|
||||
|
||||
## Chat
|
||||
|
||||
- [ ] Chat view renders message history
|
||||
- [ ] Sending a message works and response streams in
|
||||
- [ ] Markdown rendering works in responses (code blocks, lists, links)
|
||||
- [ ] Tool call cards render collapsed by default
|
||||
- [ ] Tool cards expand/collapse on click; summary shows tool name/count
|
||||
- [ ] JSON messages render collapsed by default
|
||||
- [ ] Delete message: trash icon appears on hover, click removes message group
|
||||
- [ ] Deleted messages persist across reload (localStorage)
|
||||
- [ ] Clear history button resets session via `sessions.reset` RPC
|
||||
- [ ] Agent selector dropdown appears when multiple agents configured
|
||||
- [ ] Switching agents updates session key and reloads history
|
||||
- [ ] Session list panel: shows all sessions for current agent
|
||||
- [ ] Session list: clicking a session switches to it
|
||||
- [ ] Input history (up/down arrow) recalls previous messages
|
||||
- [ ] Slash command menu opens on `/` keystroke
|
||||
- [ ] Slash commands show icons, categories, and grouping
|
||||
- [ ] Pinned messages render if present
|
||||
|
||||
## Command Palette
|
||||
|
||||
- [ ] Opens via keyboard shortcut or UI button
|
||||
- [ ] Fuzzy search filters commands as you type
|
||||
- [ ] Results grouped by category with labels
|
||||
- [ ] Selecting a command executes it
|
||||
- [ ] "No results" message when nothing matches
|
||||
- [ ] Clicking overlay closes palette
|
||||
- [ ] Escape key closes palette
|
||||
|
||||
## Agents
|
||||
|
||||
- [ ] Agent tab loads agent list
|
||||
- [ ] Agent overview panel: identity card with name, ID, avatar color
|
||||
- [ ] Agent config display: model, tools, skills shown
|
||||
- [ ] Agent panels: overview, status/files, tools/skills tabs work
|
||||
- [ ] Tab counts show for files, skills, channels, cron
|
||||
- [ ] Sidebar agent filter input filters agents in multi-agent setup
|
||||
- [ ] Agent actions menu: "copy ID" and "set as default" work
|
||||
- [ ] Chip-based fallback input (model selection): Enter/comma adds chips
|
||||
|
||||
## Channels & Instances
|
||||
|
||||
- [ ] Channels tab lists connected channels
|
||||
- [ ] Instances tab lists connected instances
|
||||
- [ ] Host/IP blurred by default in Connected Instances
|
||||
- [ ] Reveal toggle shows actual host/IP values
|
||||
- [ ] Nostr profile form renders if nostr channel present
|
||||
|
||||
## Privacy & Redaction
|
||||
|
||||
- [ ] Topbar redact toggle visible; default is stream mode on
|
||||
- [ ] Redact ON: sensitive values masked in overview cards
|
||||
- [ ] Redact ON: cost digits blurred
|
||||
- [ ] Redact ON: access card blurred
|
||||
- [ ] Redact ON: raw config JSON masks sensitive values with count badge
|
||||
- [ ] Redact OFF: all values visible
|
||||
|
||||
## Config
|
||||
|
||||
- [ ] Config tab renders current gateway configuration
|
||||
- [ ] Config form fields editable
|
||||
- [ ] Sensitive config values masked when redact is on
|
||||
- [ ] Config analysis view loads
|
||||
|
||||
## Other Tabs
|
||||
|
||||
- [ ] Sessions tab loads session list
|
||||
- [ ] Usage tab loads usage statistics with styled sections
|
||||
- [ ] Cron tab lists cron jobs with status
|
||||
- [ ] Skills tab lists skills with status report
|
||||
- [ ] Nodes tab loads
|
||||
- [ ] Debug tab renders debug info
|
||||
- [ ] Logs tab renders
|
||||
|
||||
## i18n
|
||||
|
||||
- [ ] English locale loads by default
|
||||
- [ ] All visible strings use i18n keys (no hardcoded English in templates)
|
||||
- [ ] zh-CN locale keys present
|
||||
- [ ] zh-TW locale keys present
|
||||
- [ ] pt-BR locale keys present
|
||||
|
||||
## Responsive & Mobile
|
||||
|
||||
- [ ] Sidebar collapses on narrow viewport
|
||||
- [ ] Bottom tabs render on mobile breakpoint
|
||||
- [ ] Card grid reflows: 4 → 2 → 1 columns
|
||||
- [ ] Chat input usable on mobile
|
||||
- [ ] No horizontal overflow on any tab at 375px width
|
||||
|
||||
## Build & Tests
|
||||
|
||||
- [ ] `pnpm build` completes without errors
|
||||
- [ ] `pnpm test` passes — specifically `ui/` test files:
|
||||
- [ ] `app-gateway.node.test.ts`
|
||||
- [ ] `app-settings.test.ts`
|
||||
- [ ] `config-form.browser.test.ts`
|
||||
- [ ] `config.browser.test.ts`
|
||||
- [ ] `chat.test.ts`
|
||||
- [ ] No new TypeScript errors: `pnpm tsgo`
|
||||
- [ ] No lint/format issues: `pnpm check`
|
||||
@@ -21,7 +21,6 @@ export const en: TranslationMap = {
|
||||
settings: "Settings",
|
||||
expand: "Expand sidebar",
|
||||
collapse: "Collapse sidebar",
|
||||
resize: "Resize sidebar",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agents",
|
||||
@@ -39,19 +38,19 @@ export const en: TranslationMap = {
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Workspaces, tools, identities.",
|
||||
overview: "Status, entry points, health.",
|
||||
channels: "Channels and settings.",
|
||||
instances: "Connected clients and nodes.",
|
||||
sessions: "Active sessions and defaults.",
|
||||
usage: "API usage and costs.",
|
||||
cron: "Wakeups and recurring runs.",
|
||||
skills: "Skills and API keys.",
|
||||
nodes: "Paired devices and commands.",
|
||||
chat: "Gateway chat for quick interventions.",
|
||||
config: "Edit openclaw.json.",
|
||||
debug: "Snapshots, events, RPC.",
|
||||
logs: "Live gateway logs.",
|
||||
agents: "Manage agent workspaces, tools, and identities.",
|
||||
overview: "Gateway status, entry points, and a fast health read.",
|
||||
channels: "Manage channels and settings.",
|
||||
instances: "Presence beacons from connected clients and nodes.",
|
||||
sessions: "Inspect active sessions and adjust per-session defaults.",
|
||||
usage: "Monitor API usage and costs.",
|
||||
cron: "Schedule wakeups and recurring agent runs.",
|
||||
skills: "Manage skill availability and API key injection.",
|
||||
nodes: "Paired devices, capabilities, and command exposure.",
|
||||
chat: "Direct gateway chat session for quick interventions.",
|
||||
config: "Edit ~/.openclaw/openclaw.json safely.",
|
||||
debug: "Gateway snapshots, events, and manual RPC calls.",
|
||||
logs: "Live tail of the gateway file logs.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const pt_BR: TranslationMap = {
|
||||
settings: "Configurações",
|
||||
expand: "Expandir barra lateral",
|
||||
collapse: "Recolher barra lateral",
|
||||
resize: "Redimensionar barra lateral",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agentes",
|
||||
@@ -39,19 +38,19 @@ export const pt_BR: TranslationMap = {
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Espaços, ferramentas, identidades.",
|
||||
overview: "Status, entrada, saúde.",
|
||||
channels: "Canais e configurações.",
|
||||
instances: "Clientes e nós conectados.",
|
||||
sessions: "Sessões ativas e padrões.",
|
||||
usage: "Uso e custos da API.",
|
||||
cron: "Despertares e execuções.",
|
||||
skills: "Habilidades e chaves API.",
|
||||
nodes: "Dispositivos e comandos.",
|
||||
chat: "Chat do gateway para intervenções rápidas.",
|
||||
config: "Editar openclaw.json.",
|
||||
debug: "Snapshots, eventos, RPC.",
|
||||
logs: "Logs ao vivo do gateway.",
|
||||
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
|
||||
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
|
||||
channels: "Gerenciar canais e configurações.",
|
||||
instances: "Beacons de presença de clientes e nós conectados.",
|
||||
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
|
||||
usage: "Monitorar uso e custos da API.",
|
||||
cron: "Agendar despertares e execuções recorrentes de agentes.",
|
||||
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
|
||||
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
|
||||
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
|
||||
config: "Editar ~/.openclaw/openclaw.json com segurança.",
|
||||
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
|
||||
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const zh_CN: TranslationMap = {
|
||||
settings: "设置",
|
||||
expand: "展开侧边栏",
|
||||
collapse: "折叠侧边栏",
|
||||
resize: "调整侧边栏大小",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
@@ -39,19 +38,19 @@ export const zh_CN: TranslationMap = {
|
||||
logs: "日志",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "工作区、工具、身份。",
|
||||
overview: "状态、入口点、健康。",
|
||||
channels: "频道和设置。",
|
||||
instances: "已连接客户端和节点。",
|
||||
sessions: "活动会话和默认设置。",
|
||||
usage: "API 使用情况和成本。",
|
||||
cron: "唤醒和重复运行。",
|
||||
skills: "技能和 API 密钥。",
|
||||
nodes: "配对设备和命令。",
|
||||
chat: "网关聊天,快速干预。",
|
||||
config: "编辑 openclaw.json。",
|
||||
debug: "快照、事件、RPC。",
|
||||
logs: "实时网关日志。",
|
||||
agents: "管理代理工作区、工具和身份。",
|
||||
overview: "网关状态、入口点和快速健康读取。",
|
||||
channels: "管理频道和设置。",
|
||||
instances: "来自已连接客户端和节点的在线信号。",
|
||||
sessions: "检查活动会话并调整每个会话的默认设置。",
|
||||
usage: "监控 API 使用情况和成本。",
|
||||
cron: "安排唤醒和重复的代理运行。",
|
||||
skills: "管理技能可用性和 API 密钥注入。",
|
||||
nodes: "配对设备、功能和命令公开。",
|
||||
chat: "用于快速干预的直接网关聊天会话。",
|
||||
config: "安全地编辑 ~/.openclaw/openclaw.json。",
|
||||
debug: "网关快照、事件和手动 RPC 调用。",
|
||||
logs: "网关文件日志的实时追踪。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
|
||||
@@ -21,7 +21,6 @@ export const zh_TW: TranslationMap = {
|
||||
settings: "設置",
|
||||
expand: "展開側邊欄",
|
||||
collapse: "折疊側邊欄",
|
||||
resize: "調整側邊欄大小",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
@@ -39,19 +38,19 @@ export const zh_TW: TranslationMap = {
|
||||
logs: "日誌",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "工作區、工具、身份。",
|
||||
overview: "狀態、入口點、健康。",
|
||||
channels: "頻道和設置。",
|
||||
instances: "已連接客戶端和節點。",
|
||||
sessions: "活動會話和默認設置。",
|
||||
usage: "API 使用情況和成本。",
|
||||
cron: "喚醒和重複運行。",
|
||||
skills: "技能和 API 密鑰。",
|
||||
nodes: "配對設備和命令。",
|
||||
chat: "網關聊天,快速干預。",
|
||||
config: "編輯 openclaw.json。",
|
||||
debug: "快照、事件、RPC。",
|
||||
logs: "實時網關日誌。",
|
||||
agents: "管理代理工作區、工具和身份。",
|
||||
overview: "網關狀態、入口點和快速健康讀取。",
|
||||
channels: "管理頻道和設置。",
|
||||
instances: "來自已連接客戶端和節點的在線信號。",
|
||||
sessions: "檢查活動會話並調整每個會話的默認設置。",
|
||||
usage: "監控 API 使用情況和成本。",
|
||||
cron: "安排喚醒和重複的代理運行。",
|
||||
skills: "管理技能可用性和 API 密鑰注入。",
|
||||
nodes: "配對設備、功能和命令公開。",
|
||||
chat: "用於快速干預的直接網關聊天會話。",
|
||||
config: "安全地編輯 ~/.openclaw/openclaw.json。",
|
||||
debug: "網關快照、事件和手動 RPC 調用。",
|
||||
logs: "網關文件日志的實時追蹤。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
|
||||
@@ -96,55 +96,55 @@
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: light — Luxe Cream & Coral ─── */
|
||||
/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
color-scheme: dark;
|
||||
|
||||
--vscode-bg: #faf7f2;
|
||||
--vscode-sidebar: #f5f0e8;
|
||||
--vscode-panel: #fffef9;
|
||||
--vscode-panel-border: rgba(26, 22, 20, 0.08);
|
||||
--vscode-surface: #fffef9;
|
||||
--vscode-hover: #f0ebe3;
|
||||
--vscode-contrast: #f0ebe3;
|
||||
--vscode-text: #1a1614;
|
||||
--vscode-muted: #6b5d54;
|
||||
--vscode-subtle: #9c8f84;
|
||||
--vscode-ghost: #ebe6df;
|
||||
--vscode-accent: #c73526;
|
||||
--vscode-accent-alpha: rgba(199, 53, 38, 0.12);
|
||||
--vscode-selection: rgba(199, 53, 38, 0.18);
|
||||
--vscode-success: #0d9b7a;
|
||||
--vscode-danger: #c73526;
|
||||
--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: #c73526;
|
||||
--kn-claw-bright: #e85a4a;
|
||||
--kn-claw-dim: rgba(199, 53, 38, 0.14);
|
||||
--kn-claw-ember: #d94a3a;
|
||||
--kn-claw-deep: #9a2a1e;
|
||||
--kn-ocean: #faf7f2;
|
||||
--kn-ocean-bright: #fffef9;
|
||||
--kn-ocean-mid: #f5f0e8;
|
||||
--kn-ocean-dim: rgba(250, 247, 242, 0.9);
|
||||
--kn-ocean-deep: #f0ebe3;
|
||||
--kn-silver: #6b5d54;
|
||||
--kn-silver-bright: #1a1614;
|
||||
--kn-silver-dim: rgba(107, 93, 84, 0.12);
|
||||
--kn-bioluminescence: #0d9b7a;
|
||||
--kn-warm-dark: #1a1614;
|
||||
--kn-void: #ebe6df;
|
||||
--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: 12px;
|
||||
--glass-saturate: 110%;
|
||||
--glass-bg: rgba(255, 254, 249, 0.88);
|
||||
--glass-bg-elevated: rgba(255, 255, 255, 0.95);
|
||||
--glass-border: rgba(26, 22, 20, 0.1);
|
||||
--glass-border-hover: rgba(199, 53, 38, 0.35);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
--glass-shadow-sm: 0 2px 12px rgba(26, 22, 20, 0.06), 0 1px 3px rgba(26, 22, 20, 0.04);
|
||||
--glass-shadow-md: 0 8px 32px rgba(26, 22, 20, 0.08), 0 2px 8px rgba(26, 22, 20, 0.04);
|
||||
--glass-shadow-lg: 0 20px 56px rgba(26, 22, 20, 0.12), 0 4px 16px rgba(26, 22, 20, 0.06);
|
||||
--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;
|
||||
@@ -270,6 +270,64 @@
|
||||
--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"] {
|
||||
@@ -337,6 +395,7 @@
|
||||
: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);
|
||||
@@ -491,16 +550,6 @@
|
||||
--agent-tab-hover-bg: var(--vscode-accent-alpha);
|
||||
}
|
||||
|
||||
/* Light theme semantic overrides (accent buttons need dark text) */
|
||||
:root[data-theme="light"] {
|
||||
--card-highlight: rgba(255, 255, 255, 0.85);
|
||||
--accent-foreground: #ffffff;
|
||||
--primary-foreground: #ffffff;
|
||||
--destructive-foreground: #ffffff;
|
||||
--focus-offset-color: var(--bg);
|
||||
--grid-line: rgba(26, 22, 20, 0.06);
|
||||
}
|
||||
|
||||
/* ─── Accessibility: High Contrast ─── */
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@@ -724,17 +773,16 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── light — Luxe Cream ambient gradient ─── */
|
||||
/* ─── openai — Crimson atmosphere ─── */
|
||||
|
||||
:root[data-theme="light"] body {
|
||||
:root[data-theme="openai"] body {
|
||||
background:
|
||||
radial-gradient(ellipse 90% 60% at 50% -15%, rgba(199, 53, 38, 0.04) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 70% 50% at 85% 40%, rgba(13, 155, 122, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(ellipse 60% 40% at 15% 80%, rgba(199, 53, 38, 0.02) 0%, transparent 45%),
|
||||
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="light"] body::after {
|
||||
:root[data-theme="openai"] body::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,9 @@
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.agent-chat__badge svg,
|
||||
.agent-chat__badge img {
|
||||
.agent-chat__badge svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
object-fit: contain;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
/* ─── Starter Cards ─── */
|
||||
@@ -242,17 +239,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--logo {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--logo svg,
|
||||
.agent-chat__avatar--logo img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -1138,51 +1124,164 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
|
||||
padding: 6px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-agent-bar__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-agent-bar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-agent-bar__name {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-agent-select {
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 0 14px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 4px 24px 4px 8px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 24 24' fill='none' stroke='%237d8590' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237d8590' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0 center;
|
||||
background-position: right 6px center;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
.chat-agent-select:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
}
|
||||
|
||||
.chat-agent-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* ─── Sessions Panel ─── */
|
||||
|
||||
.chat-sessions-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-sessions-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
transition:
|
||||
color 150ms ease,
|
||||
background 150ms ease;
|
||||
}
|
||||
|
||||
.chat-sessions-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sessions-summary::before {
|
||||
content: "▸";
|
||||
font-size: 9px;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.chat-sessions-panel[open] > .chat-sessions-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.chat-sessions-summary:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--bg-hover) 60%, transparent);
|
||||
}
|
||||
|
||||
.chat-sessions-summary svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.chat-sessions-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
background: var(--popover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.chat-session-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-session-item--active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-session-item__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-session-item__meta {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 16px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
@@ -124,13 +124,6 @@ img.chat-avatar {
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Logo avatar (OpenClaw favicon) - contain to show full logo */
|
||||
img.chat-avatar.chat-avatar--logo {
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/* Allow flex shrinking */
|
||||
min-height: 0; /* Allow flex shrinking */
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -22,18 +21,18 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -41,23 +40,21 @@
|
||||
.chat-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: 140px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* Chat thread - scrollable middle section, transparent */
|
||||
.chat-thread {
|
||||
flex: 1 1 0;
|
||||
/* Grow, shrink, and use 0 base for proper scrolling */
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 6px 6px 4px;
|
||||
padding: 14px 8px;
|
||||
margin: 0 -4px;
|
||||
min-height: 0;
|
||||
/* Allow shrinking for flex scroll behavior */
|
||||
min-height: 0; /* Allow shrinking for flex scroll behavior */
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -154,10 +151,9 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
/* Push to bottom of flex container */
|
||||
padding: 6px 6px 6px;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 14px 6px 6px;
|
||||
background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
@@ -174,8 +170,7 @@
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-self: flex-start;
|
||||
/* Don't stretch in flex column parent */
|
||||
align-self: flex-start; /* Don't stretch in flex column parent */
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
@@ -323,13 +318,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
min-width: 140px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
@@ -341,9 +336,9 @@
|
||||
|
||||
/* Icon button style */
|
||||
.btn--icon {
|
||||
padding: 0 !important;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 8px !important;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -352,17 +347,12 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Controls separator — renders as a thin vertical divider */
|
||||
/* Controls separator */
|
||||
.chat-controls__separator {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
font-size: 0;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
color: var(--border);
|
||||
font-size: 18px;
|
||||
margin: 0 8px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.btn--icon:hover {
|
||||
@@ -370,18 +360,24 @@
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Ensure chat toolbar toggles have a clearly visible active state. */
|
||||
.chat-controls .btn--icon.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
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);
|
||||
}
|
||||
|
||||
.btn--icon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -390,9 +386,9 @@
|
||||
}
|
||||
|
||||
.chat-controls__session select {
|
||||
padding: 0 28px 0 10px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
max-width: 220px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -402,16 +398,15 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
height: 32px;
|
||||
padding: 4px 10px;
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-session {
|
||||
min-width: 80px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
@@ -420,15 +415,10 @@
|
||||
|
||||
.chat-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 80px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.chat-controls__separator {
|
||||
display: none;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
container-type: inline-size;
|
||||
min-width: 300px;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -78,14 +77,11 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
@@ -101,38 +97,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Minimal state when sidebar is narrow: hide content, show expand hint */
|
||||
@container (max-width: 260px) {
|
||||
.chat-sidebar .sidebar-header {
|
||||
padding: 6px 8px;
|
||||
border-bottom: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content::before {
|
||||
content: "← Drag to expand";
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: Full-screen modal */
|
||||
@media (max-width: 768px) {
|
||||
.chat-split-container--open {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
||||
=========================================== */
|
||||
|
||||
.shell {
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 52px;
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 240px;
|
||||
--shell-topbar-height: 62px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
@@ -80,8 +80,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
@@ -102,7 +102,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -142,17 +142,17 @@
|
||||
.topbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
min-width: 200px;
|
||||
max-width: 340px;
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
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: 12px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -220,10 +220,10 @@
|
||||
.topbar-connection {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
@@ -262,8 +262,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
@@ -278,8 +278,8 @@
|
||||
}
|
||||
|
||||
.topbar-redact svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.topbar-redact:hover {
|
||||
@@ -298,29 +298,23 @@
|
||||
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Topbar theme select sizing */
|
||||
/* Topbar theme toggle sizing */
|
||||
|
||||
.topbar-status .theme-select {
|
||||
height: 26px;
|
||||
min-width: 82px;
|
||||
font-size: 11px;
|
||||
.topbar-status .theme-toggle {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.shell-nav {
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
@@ -341,9 +335,13 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--chat-focus .sidebar,
|
||||
.shell--chat-focus .sidebar-resizer {
|
||||
display: none;
|
||||
.shell--chat-focus .sidebar {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
@@ -385,21 +383,6 @@
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .nav-item--active::before {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 120% 28px at 50% -2px,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent) 40%,
|
||||
transparent 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 100% at -4px 50%,
|
||||
color-mix(in srgb, var(--accent) 28%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -414,54 +397,24 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Sidebar resizer handle */
|
||||
.sidebar-resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
/* Hit area extends beyond visible handle for easier grabbing */
|
||||
margin-right: -3px;
|
||||
}
|
||||
|
||||
.sidebar-resizer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--glass-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-resizer:hover::before,
|
||||
.sidebar-resizer:active::before {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Sidebar header (brand + collapse) */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 8px;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
max-height: 28px;
|
||||
|
||||
padding-left: 10px;
|
||||
@@ -487,19 +440,13 @@
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -636,7 +583,6 @@
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
@@ -709,33 +655,6 @@
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.nav-item--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
ellipse 28px 120% at -2px 50%,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent) 40%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
animation: nav-glow-in 0.4s ease-out 0.05s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes nav-glow-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item--active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
@@ -749,17 +668,11 @@
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-footer__docs-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 2px 12px 6px;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.sidebar-version__text {
|
||||
@@ -783,7 +696,7 @@
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 12px 14px 24px;
|
||||
padding: 14px 18px 36px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -791,13 +704,13 @@
|
||||
}
|
||||
|
||||
.content > * + * {
|
||||
margin-top: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 24px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -809,12 +722,10 @@
|
||||
/* Content header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
gap: 16px;
|
||||
padding: 4px 0;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
@@ -822,37 +733,36 @@
|
||||
transform var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 36px;
|
||||
max-height: 80px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0;
|
||||
max-height: 0px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.25;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
margin-top: 1px;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Chat view header adjustments */
|
||||
@@ -860,13 +770,10 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.content--chat .content-header > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -876,66 +783,6 @@
|
||||
|
||||
.content--chat .chat-controls {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
/* Chat controls in header — uniform 32px height across all controls */
|
||||
.content-header .btn--icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.content-header .btn--icon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__session select {
|
||||
height: 32px;
|
||||
line-height: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 0 28px 0 10px;
|
||||
background-position: right 8px center;
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__separator {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
font-size: 0;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__thinking {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
@@ -944,7 +791,7 @@
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
@@ -957,32 +804,32 @@
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.note-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -992,14 +839,58 @@
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-pad: 10px;
|
||||
--shell-gap: 10px;
|
||||
--shell-nav-width: 200px;
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
"topbar"
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
|
||||
.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;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
position: static;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-search__kbd {
|
||||
@@ -1010,30 +901,6 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10px 12px 20px;
|
||||
}
|
||||
|
||||
.content > * + * {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -2,10 +2,60 @@
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: keep side nav vertical, narrow sidebar */
|
||||
/* Tablet: Horizontal nav */
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-nav-width: 200px;
|
||||
.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;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +64,6 @@
|
||||
.shell {
|
||||
--shell-pad: 8px;
|
||||
--shell-gap: 8px;
|
||||
--shell-nav-width: 180px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
@@ -91,31 +140,14 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Content — compact header on chat, hide on other tabs */
|
||||
/* Content */
|
||||
.content-header {
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 12px 0;
|
||||
/* This controls the height of the content header on mobile */
|
||||
max-height: 64px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.content:not(.content--chat) .content-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content--chat .page-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content--chat .page-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 6px 12px;
|
||||
gap: 10px;
|
||||
padding: 4px 4px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
@@ -180,14 +212,10 @@
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-agent-bar {
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
@@ -205,60 +233,40 @@
|
||||
}
|
||||
|
||||
.chat-thread {
|
||||
margin-top: 6px;
|
||||
padding: 10px 6px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 92%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 6px 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-compose__field textarea {
|
||||
min-height: 52px;
|
||||
padding: 6px 10px;
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.agent-chat__input {
|
||||
margin: 0 8px 10px;
|
||||
}
|
||||
|
||||
.agent-chat__toolbar {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.agent-chat__input-btn,
|
||||
.agent-chat__toolbar .btn-ghost {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.agent-chat__input-btn svg,
|
||||
.agent-chat__toolbar .btn-ghost svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Log stream */
|
||||
.log-stream {
|
||||
border-radius: var(--radius-md);
|
||||
max-height: 320px;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.log-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
@@ -274,15 +282,7 @@
|
||||
}
|
||||
|
||||
.log-message {
|
||||
font-size: 11px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ov-log-tail-content {
|
||||
max-height: 200px;
|
||||
font-size: 10px;
|
||||
padding: 8px;
|
||||
line-height: 1.5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
@@ -306,10 +306,18 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
height: 26px;
|
||||
min-width: 78px;
|
||||
font-size: 11px;
|
||||
.theme-toggle {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.theme-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,9 +380,12 @@
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.theme-select {
|
||||
height: 24px;
|
||||
min-width: 72px;
|
||||
font-size: 10px;
|
||||
.theme-toggle {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,72 +13,82 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
|
||||
* ================================================================ */
|
||||
|
||||
describe("parseSessionKey", () => {
|
||||
it("maps session keys to expected prefixes and fallback names", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "bare main",
|
||||
key: "main",
|
||||
expected: { prefix: "", fallbackName: "Main Session" },
|
||||
},
|
||||
{
|
||||
name: "agent main key",
|
||||
key: "agent:main:main",
|
||||
expected: { prefix: "", fallbackName: "Main Session" },
|
||||
},
|
||||
{
|
||||
name: "subagent key",
|
||||
key: "agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253",
|
||||
expected: { prefix: "Subagent:", fallbackName: "Subagent:" },
|
||||
},
|
||||
{
|
||||
name: "cron key",
|
||||
key: "agent:main:cron:daily-briefing-uuid",
|
||||
expected: { prefix: "Cron:", fallbackName: "Cron Job:" },
|
||||
},
|
||||
{
|
||||
name: "direct known channel",
|
||||
key: "agent:main:bluebubbles:direct:+19257864429",
|
||||
expected: { prefix: "", fallbackName: "iMessage · +19257864429" },
|
||||
},
|
||||
{
|
||||
name: "direct telegram",
|
||||
key: "agent:main:telegram:direct:user123",
|
||||
expected: { prefix: "", fallbackName: "Telegram · user123" },
|
||||
},
|
||||
{
|
||||
name: "group known channel",
|
||||
key: "agent:main:discord:group:guild-chan",
|
||||
expected: { prefix: "", fallbackName: "Discord Group" },
|
||||
},
|
||||
{
|
||||
name: "unknown channel direct",
|
||||
key: "agent:main:mychannel:direct:user1",
|
||||
expected: { prefix: "", fallbackName: "Mychannel · user1" },
|
||||
},
|
||||
{
|
||||
name: "legacy channel-prefixed key",
|
||||
key: "bluebubbles:g-agent-main-bluebubbles-direct-+19257864429",
|
||||
expected: { prefix: "", fallbackName: "iMessage Session" },
|
||||
},
|
||||
{
|
||||
name: "legacy discord key",
|
||||
key: "discord:123:456",
|
||||
expected: { prefix: "", fallbackName: "Discord Session" },
|
||||
},
|
||||
{
|
||||
name: "bare channel key",
|
||||
key: "telegram",
|
||||
expected: { prefix: "", fallbackName: "Telegram Session" },
|
||||
},
|
||||
{
|
||||
name: "unknown pattern",
|
||||
key: "something-unknown",
|
||||
expected: { prefix: "", fallbackName: "something-unknown" },
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(parseSessionKey(testCase.key), testCase.name).toEqual(testCase.expected);
|
||||
}
|
||||
it("identifies main session (bare 'main')", () => {
|
||||
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
|
||||
});
|
||||
|
||||
it("identifies main session (agent:main:main)", () => {
|
||||
expect(parseSessionKey("agent:main:main")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Main Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies subagent sessions", () => {
|
||||
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
|
||||
prefix: "Subagent:",
|
||||
fallbackName: "Subagent:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies cron sessions", () => {
|
||||
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
|
||||
prefix: "Cron:",
|
||||
fallbackName: "Cron Job:",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage · +19257864429",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies direct chat with telegram", () => {
|
||||
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram · user123",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies group chat with known channel", () => {
|
||||
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Group",
|
||||
});
|
||||
});
|
||||
|
||||
it("capitalises unknown channels in direct/group patterns", () => {
|
||||
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Mychannel · user1",
|
||||
});
|
||||
});
|
||||
|
||||
it("identifies channel-prefixed legacy keys", () => {
|
||||
expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "iMessage Session",
|
||||
});
|
||||
expect(parseSessionKey("discord:123:456")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Discord Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles bare channel name as key", () => {
|
||||
expect(parseSessionKey("telegram")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "Telegram Session",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(parseSessionKey("something-unknown")).toEqual({
|
||||
prefix: "",
|
||||
fallbackName: "something-unknown",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,130 +97,167 @@ describe("parseSessionKey", () => {
|
||||
* ================================================================ */
|
||||
|
||||
describe("resolveSessionDisplayName", () => {
|
||||
it("resolves key-only fallbacks", () => {
|
||||
const cases = [
|
||||
{ key: "agent:main:main", expected: "Main Session" },
|
||||
{ key: "main", expected: "Main Session" },
|
||||
{ key: "agent:main:subagent:abc-123", expected: "Subagent:" },
|
||||
{ key: "agent:main:cron:abc-123", expected: "Cron Job:" },
|
||||
{ key: "agent:main:bluebubbles:direct:+19257864429", expected: "iMessage · +19257864429" },
|
||||
{ key: "discord:123:456", expected: "Discord Session" },
|
||||
{ key: "something-custom", expected: "something-custom" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(resolveSessionDisplayName(testCase.key), testCase.key).toBe(testCase.expected);
|
||||
}
|
||||
// ── Key-only fallbacks (no row) ──────────────────
|
||||
|
||||
it("returns 'Main Session' for agent:main:main key", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("resolves row labels/display names and typed prefixes", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "row with no label/display",
|
||||
key: "agent:main:main",
|
||||
rowData: row({ key: "agent:main:main" }),
|
||||
expected: "Main Session",
|
||||
},
|
||||
{
|
||||
name: "displayName equals key",
|
||||
key: "mykey",
|
||||
rowData: row({ key: "mykey", displayName: "mykey" }),
|
||||
expected: "mykey",
|
||||
},
|
||||
{
|
||||
name: "label equals key",
|
||||
key: "mykey",
|
||||
rowData: row({ key: "mykey", label: "mykey" }),
|
||||
expected: "mykey",
|
||||
},
|
||||
{
|
||||
name: "label used",
|
||||
key: "discord:123:456",
|
||||
rowData: row({ key: "discord:123:456", label: "General" }),
|
||||
expected: "General",
|
||||
},
|
||||
{
|
||||
name: "displayName fallback",
|
||||
key: "discord:123:456",
|
||||
rowData: row({ key: "discord:123:456", displayName: "My Chat" }),
|
||||
expected: "My Chat",
|
||||
},
|
||||
{
|
||||
name: "label preferred over displayName",
|
||||
key: "discord:123:456",
|
||||
rowData: row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||||
expected: "General",
|
||||
},
|
||||
{
|
||||
name: "ignore whitespace label",
|
||||
key: "discord:123:456",
|
||||
rowData: row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||||
expected: "My Chat",
|
||||
},
|
||||
{
|
||||
name: "fallback when whitespace label and no displayName",
|
||||
key: "discord:123:456",
|
||||
rowData: row({ key: "discord:123:456", label: " " }),
|
||||
expected: "Discord Session",
|
||||
},
|
||||
{
|
||||
name: "trim label",
|
||||
key: "k",
|
||||
rowData: row({ key: "k", label: " General " }),
|
||||
expected: "General",
|
||||
},
|
||||
{
|
||||
name: "trim displayName",
|
||||
key: "k",
|
||||
rowData: row({ key: "k", displayName: " My Chat " }),
|
||||
expected: "My Chat",
|
||||
},
|
||||
{
|
||||
name: "prefix subagent label",
|
||||
key: "agent:main:subagent:abc-123",
|
||||
rowData: row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||||
expected: "Subagent: maintainer-v2",
|
||||
},
|
||||
{
|
||||
name: "prefix subagent displayName",
|
||||
key: "agent:main:subagent:abc-123",
|
||||
rowData: row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||||
expected: "Subagent: Task Runner",
|
||||
},
|
||||
{
|
||||
name: "prefix cron label",
|
||||
key: "agent:main:cron:abc-123",
|
||||
rowData: row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||||
expected: "Cron: daily-briefing",
|
||||
},
|
||||
{
|
||||
name: "prefix cron displayName",
|
||||
key: "agent:main:cron:abc-123",
|
||||
rowData: row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||||
expected: "Cron: Nightly Sync",
|
||||
},
|
||||
{
|
||||
name: "avoid double cron prefix",
|
||||
key: "agent:main:cron:abc-123",
|
||||
rowData: row({ key: "agent:main:cron:abc-123", label: "Cron: Nightly Sync" }),
|
||||
expected: "Cron: Nightly Sync",
|
||||
},
|
||||
{
|
||||
name: "avoid double subagent prefix",
|
||||
key: "agent:main:subagent:abc-123",
|
||||
rowData: row({ key: "agent:main:subagent:abc-123", displayName: "Subagent: Runner" }),
|
||||
expected: "Subagent: Runner",
|
||||
},
|
||||
{
|
||||
name: "non-typed label without prefix",
|
||||
key: "agent:main:bluebubbles:direct:+19257864429",
|
||||
rowData: row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
|
||||
expected: "Tyler",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(resolveSessionDisplayName(testCase.key, testCase.rowData), testCase.name).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
it("returns 'Main Session' for bare 'main' key", () => {
|
||||
expect(resolveSessionDisplayName("main")).toBe("Main Session");
|
||||
});
|
||||
|
||||
it("returns 'Subagent:' for subagent key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
|
||||
});
|
||||
|
||||
it("returns 'Cron Job:' for cron key without row", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
|
||||
});
|
||||
|
||||
it("parses direct chat key with channel", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe(
|
||||
"iMessage · +19257864429",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses channel-prefixed legacy key", () => {
|
||||
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("returns raw key for unknown patterns", () => {
|
||||
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
|
||||
});
|
||||
|
||||
// ── With row data (label / displayName) ──────────
|
||||
|
||||
it("returns parsed fallback when row has no label or displayName", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||||
"Main Session",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed fallback when displayName matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
|
||||
"mykey",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns parsed fallback when label matches key", () => {
|
||||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
|
||||
});
|
||||
|
||||
it("uses label alone when available", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", label: "General" }),
|
||||
),
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("falls back to displayName when label is absent", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||||
),
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("prefers label over displayName when both are present", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||||
),
|
||||
).toBe("General");
|
||||
});
|
||||
|
||||
it("ignores whitespace-only label and falls back to displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"discord:123:456",
|
||||
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||||
),
|
||||
).toBe("My Chat");
|
||||
});
|
||||
|
||||
it("uses parsed fallback when whitespace-only label and no displayName", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
|
||||
).toBe("Discord Session");
|
||||
});
|
||||
|
||||
it("trims label and displayName", () => {
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
|
||||
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
|
||||
"My Chat",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Type prefixes applied to labels / displayNames ──
|
||||
|
||||
it("prefixes subagent label with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||||
),
|
||||
).toBe("Subagent: maintainer-v2");
|
||||
});
|
||||
|
||||
it("prefixes subagent displayName with Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||||
),
|
||||
).toBe("Subagent: Task Runner");
|
||||
});
|
||||
|
||||
it("prefixes cron label with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||||
),
|
||||
).toBe("Cron: daily-briefing");
|
||||
});
|
||||
|
||||
it("prefixes cron displayName with Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||||
),
|
||||
).toBe("Cron: Nightly Sync");
|
||||
});
|
||||
|
||||
it("does not double-prefix cron labels that already include Cron:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:cron:abc-123",
|
||||
row({ key: "agent:main:cron:abc-123", label: "Cron: Nightly Sync" }),
|
||||
),
|
||||
).toBe("Cron: Nightly Sync");
|
||||
});
|
||||
|
||||
it("does not double-prefix subagent display names that already include Subagent:", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:subagent:abc-123",
|
||||
row({ key: "agent:main:subagent:abc-123", displayName: "Subagent: Runner" }),
|
||||
),
|
||||
).toBe("Subagent: Runner");
|
||||
});
|
||||
|
||||
it("does not prefix non-typed sessions with labels", () => {
|
||||
expect(
|
||||
resolveSessionDisplayName(
|
||||
"agent:main:bluebubbles:direct:+19257864429",
|
||||
row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
|
||||
),
|
||||
).toBe("Tyler");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,59 +84,18 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatSessionSelect(state: AppViewState) {
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
||||
const sessionOptions = resolveSessionOptions(
|
||||
state.sessionKey,
|
||||
state.sessionsResult,
|
||||
mainSessionKey,
|
||||
);
|
||||
return html`
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const disableThinkingToggle = state.onboarding;
|
||||
const disableFocusToggle = state.onboarding;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`
|
||||
<svg
|
||||
width="18"
|
||||
@@ -172,6 +131,43 @@ export function renderChatControls(state: AppViewState) {
|
||||
`;
|
||||
return html`
|
||||
<div class="chat-controls">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm btn--icon"
|
||||
?disabled=${state.chatLoading || !state.connected}
|
||||
@@ -400,30 +396,56 @@ function resolveSessionOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
type ThemeOption = { id: ThemeMode; label: string };
|
||||
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "dark", label: "Claw" },
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "openknot", label: "Knot" },
|
||||
{ id: "fieldmanual", label: "Field" },
|
||||
{ id: "clawdash", label: "Chrome" },
|
||||
{ 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 app = state as unknown as OpenClawApp;
|
||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const context: ThemeTransitionContext = { element };
|
||||
if (event.clientX || event.clientY) {
|
||||
context.pointerClientX = event.clientX;
|
||||
context.pointerClientY = event.clientY;
|
||||
}
|
||||
state.setTheme(next, context);
|
||||
};
|
||||
|
||||
const handleCollapse = () => app.handleThemeToggleCollapse();
|
||||
|
||||
return html`
|
||||
<select
|
||||
class="theme-select"
|
||||
.value=${state.theme}
|
||||
aria-label="Theme"
|
||||
title="Theme"
|
||||
@change=${(e: Event) => {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
const next = select.value as ThemeMode;
|
||||
const context: ThemeTransitionContext = { element: select };
|
||||
state.setTheme(next, context);
|
||||
<div
|
||||
class="theme-toggle"
|
||||
@mouseleave=${handleCollapse}
|
||||
@focusout=${(e: FocusEvent) => {
|
||||
const toggle = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (!toggle.contains(document.activeElement)) {
|
||||
handleCollapse();
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
${THEME_OPTIONS.map((opt) => html`<option value=${opt.id}>${opt.label}</option>`)}
|
||||
</select>
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.ts";
|
||||
import { renderUsageTab } from "./app-render-usage-tab.ts";
|
||||
import {
|
||||
renderChatControls,
|
||||
renderChatSessionSelect,
|
||||
renderTab,
|
||||
renderThemeToggle,
|
||||
} from "./app-render.helpers.ts";
|
||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
@@ -64,6 +59,7 @@ 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";
|
||||
@@ -83,33 +79,6 @@ import { renderSkills } from "./views/skills.ts";
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
|
||||
const NAV_WIDTH_MIN = 180;
|
||||
const NAV_WIDTH_MAX = 400;
|
||||
|
||||
function handleNavResizeStart(e: MouseEvent, state: AppViewState) {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = state.settings.navWidth;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta)));
|
||||
state.applySettings({ ...state.settings, navWidth: next });
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const list = state.agentsList?.agents ?? [];
|
||||
const parsed = parseAgentSessionKey(state.sessionKey);
|
||||
@@ -171,15 +140,11 @@ export function renderApp(state: AppViewState) {
|
||||
onNavigate: (tab) => {
|
||||
state.setTab(tab as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSlashCommand: (cmd) => {
|
||||
onSlashCommand: (_cmd) => {
|
||||
state.setTab("chat" as import("./navigation.ts").Tab);
|
||||
state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `;
|
||||
},
|
||||
})}
|
||||
<div
|
||||
class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}"
|
||||
style="--shell-nav-width: ${state.settings.navWidth}px"
|
||||
>
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
<header class="topbar">
|
||||
<dashboard-header .tab=${state.tab}></dashboard-header>
|
||||
<button
|
||||
@@ -194,6 +159,23 @@ export function renderApp(state: AppViewState) {
|
||||
<kbd class="topbar-search__kbd">⌘K</kbd>
|
||||
</button>
|
||||
<div class="topbar-status">
|
||||
<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>
|
||||
@@ -202,7 +184,6 @@ export function renderApp(state: AppViewState) {
|
||||
${renderThemeToggle(state)}
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell-nav">
|
||||
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
|
||||
<div class="sidebar-header">
|
||||
${
|
||||
@@ -268,61 +249,42 @@ export function renderApp(state: AppViewState) {
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-footer__docs-block">
|
||||
<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`
|
||||
<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>
|
||||
<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>
|
||||
: 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>
|
||||
${
|
||||
!state.settings.navCollapsed && !chatFocus
|
||||
? html`
|
||||
<div
|
||||
class="sidebar-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="${t("nav.resize")}"
|
||||
title="${t("nav.resize")}"
|
||||
@mousedown=${(ev: MouseEvent) => handleNavResizeStart(ev, state)}
|
||||
></div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
${
|
||||
state.updateAvailable
|
||||
@@ -339,14 +301,8 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
<section class="content-header">
|
||||
<div>
|
||||
${
|
||||
isChat
|
||||
? renderChatSessionSelect(state)
|
||||
: state.tab === "skills"
|
||||
? nothing
|
||||
: html`<div class="page-title">${titleForTab(state.tab)}</div>`
|
||||
}
|
||||
${isChat || state.tab === "skills" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-title">${titleForTab(state.tab)}</div>`}
|
||||
${state.tab === "usage" ? nothing : html`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||
@@ -468,37 +424,12 @@ export function renderApp(state: AppViewState) {
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
searchQuery: state.sessionsSearchQuery,
|
||||
sortColumn: state.sessionsSortColumn,
|
||||
sortDir: state.sessionsSortDir,
|
||||
page: state.sessionsPage,
|
||||
pageSize: state.sessionsPageSize,
|
||||
actionsOpenKey: state.sessionsActionsOpenKey,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onSearchChange: (q) => {
|
||||
state.sessionsSearchQuery = q;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onSortChange: (col, dir) => {
|
||||
state.sessionsSortColumn = col;
|
||||
state.sessionsSortDir = dir;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onPageChange: (p) => {
|
||||
state.sessionsPage = p;
|
||||
},
|
||||
onPageSizeChange: (s) => {
|
||||
state.sessionsPageSize = s;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onActionsOpenChange: (key) => {
|
||||
state.sessionsActionsOpenKey = key;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSessionAndRefresh(state, key),
|
||||
@@ -540,7 +471,6 @@ export function renderApp(state: AppViewState) {
|
||||
${
|
||||
state.tab === "agents"
|
||||
? renderAgents({
|
||||
basePath: state.basePath ?? "",
|
||||
loading: state.agentsLoading,
|
||||
error: state.agentsError,
|
||||
agentsList: state.agentsList,
|
||||
@@ -583,6 +513,10 @@ export function renderApp(state: AppViewState) {
|
||||
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) ?? [];
|
||||
@@ -1118,7 +1052,6 @@ export function renderApp(state: AppViewState) {
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
basePath: state.basePath ?? "",
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -1210,7 +1143,10 @@ export function renderApp(state: AppViewState) {
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
${renderGatewayUrlConfirmation(state)}
|
||||
${nothing}
|
||||
${renderBottomTabs({
|
||||
activeTab: state.tab,
|
||||
onTabChange: (tab) => state.setTab(tab),
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -61,39 +61,45 @@ function createScrollEvent(scrollHeight: number, scrollTop: number, clientHeight
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("handleChatScroll", () => {
|
||||
it("updates near-bottom state across threshold boundaries", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "clearly near bottom",
|
||||
event: createScrollEvent(2000, 1600, 400),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "just under threshold",
|
||||
event: createScrollEvent(2000, 1151, 400),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exactly at threshold",
|
||||
event: createScrollEvent(2000, 1150, 400),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "well above threshold",
|
||||
event: createScrollEvent(2000, 500, 400),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "scrolled up beyond long message",
|
||||
event: createScrollEvent(2000, 1100, 400),
|
||||
expected: false,
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const { host } = createScrollHost({});
|
||||
handleChatScroll(host, testCase.event);
|
||||
expect(host.chatUserNearBottom, testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
it("sets chatUserNearBottom=true when within the 450px threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1600 - 400 = 0 → clearly near bottom
|
||||
const event = createScrollEvent(2000, 1600, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=true when distance is just under threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1151 - 400 = 449 → just under threshold
|
||||
const event = createScrollEvent(2000, 1151, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(true);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when distance is exactly at threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1150 - 400 = 450 → at threshold (uses strict <)
|
||||
const event = createScrollEvent(2000, 1150, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when scrolled well above threshold", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 500 - 400 = 1100 → way above threshold
|
||||
const event = createScrollEvent(2000, 500, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
|
||||
it("sets chatUserNearBottom=false when user scrolled up past one long message (>200px <450px)", () => {
|
||||
const { host } = createScrollHost({});
|
||||
// distanceFromBottom = 2000 - 1250 - 400 = 350 → old threshold would say "near", new says "near"
|
||||
// distanceFromBottom = 2000 - 1100 - 400 = 500 → old threshold would say "not near", new also "not near"
|
||||
const event = createScrollEvent(2000, 1100, 400);
|
||||
handleChatScroll(host, event);
|
||||
expect(host.chatUserNearBottom).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,67 +121,85 @@ describe("scheduleChatScroll", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("respects near-bottom, force, and initial-load behavior", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "near-bottom auto-scroll",
|
||||
scrollTop: 1600,
|
||||
chatUserNearBottom: true,
|
||||
chatHasAutoScrolled: false,
|
||||
force: false,
|
||||
expectedScrollsToBottom: true,
|
||||
expectedNewMessagesBelow: false,
|
||||
},
|
||||
{
|
||||
name: "scrolled-up no-force",
|
||||
scrollTop: 500,
|
||||
chatUserNearBottom: false,
|
||||
chatHasAutoScrolled: false,
|
||||
force: false,
|
||||
expectedScrollsToBottom: false,
|
||||
expectedNewMessagesBelow: true,
|
||||
},
|
||||
{
|
||||
name: "scrolled-up force after initial load",
|
||||
scrollTop: 500,
|
||||
chatUserNearBottom: false,
|
||||
chatHasAutoScrolled: true,
|
||||
force: true,
|
||||
expectedScrollsToBottom: false,
|
||||
expectedNewMessagesBelow: true,
|
||||
},
|
||||
{
|
||||
name: "scrolled-up force on initial load",
|
||||
scrollTop: 500,
|
||||
chatUserNearBottom: false,
|
||||
chatHasAutoScrolled: false,
|
||||
force: true,
|
||||
expectedScrollsToBottom: true,
|
||||
expectedNewMessagesBelow: false,
|
||||
},
|
||||
] as const;
|
||||
it("scrolls to bottom when user is near bottom (no force)", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 1600,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// distanceFromBottom = 2000 - 1600 - 400 = 0 → near bottom
|
||||
host.chatUserNearBottom = true;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: testCase.scrollTop,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = testCase.chatUserNearBottom;
|
||||
host.chatHasAutoScrolled = testCase.chatHasAutoScrolled;
|
||||
host.chatNewMessagesBelow = false;
|
||||
const originalScrollTop = container.scrollTop;
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
scheduleChatScroll(host, testCase.force);
|
||||
await host.updateComplete;
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
|
||||
if (testCase.expectedScrollsToBottom) {
|
||||
expect(container.scrollTop, testCase.name).toBe(container.scrollHeight);
|
||||
} else {
|
||||
expect(container.scrollTop, testCase.name).toBe(originalScrollTop);
|
||||
}
|
||||
expect(host.chatNewMessagesBelow, testCase.name).toBe(testCase.expectedNewMessagesBelow);
|
||||
}
|
||||
it("does NOT scroll when user is scrolled up and no force", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// distanceFromBottom = 2000 - 500 - 400 = 1100 → not near bottom
|
||||
host.chatUserNearBottom = false;
|
||||
const originalScrollTop = container.scrollTop;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(container.scrollTop).toBe(originalScrollTop);
|
||||
});
|
||||
|
||||
it("does NOT scroll with force=true when user has explicitly scrolled up", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
// User has scrolled up — chatUserNearBottom is false
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = true; // Already past initial load
|
||||
const originalScrollTop = container.scrollTop;
|
||||
|
||||
scheduleChatScroll(host, true);
|
||||
await host.updateComplete;
|
||||
|
||||
// force=true should still NOT override explicit user scroll-up after initial load
|
||||
expect(container.scrollTop).toBe(originalScrollTop);
|
||||
});
|
||||
|
||||
it("DOES scroll with force=true on initial load (chatHasAutoScrolled=false)", async () => {
|
||||
const { host, container } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = false; // Initial load
|
||||
|
||||
scheduleChatScroll(host, true);
|
||||
await host.updateComplete;
|
||||
|
||||
// On initial load, force should work regardless
|
||||
expect(container.scrollTop).toBe(container.scrollHeight);
|
||||
});
|
||||
|
||||
it("sets chatNewMessagesBelow when not scrolling due to user position", async () => {
|
||||
const { host } = createScrollHost({
|
||||
scrollHeight: 2000,
|
||||
scrollTop: 500,
|
||||
clientHeight: 400,
|
||||
});
|
||||
host.chatUserNearBottom = false;
|
||||
host.chatHasAutoScrolled = true;
|
||||
host.chatNewMessagesBelow = false;
|
||||
|
||||
scheduleChatScroll(host);
|
||||
await host.updateComplete;
|
||||
|
||||
expect(host.chatNewMessagesBelow).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
navWidth: 220,
|
||||
},
|
||||
theme: "dark",
|
||||
themeResolved: "dark",
|
||||
|
||||
@@ -269,7 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
root.style.colorScheme = resolved === "light" ? "light" : "dark";
|
||||
root.style.colorScheme = "dark";
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
|
||||
@@ -146,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;
|
||||
@@ -153,12 +154,6 @@ export type AppViewState = {
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
sessionsSearchQuery: string;
|
||||
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
sessionsSortDir: "asc" | "desc";
|
||||
sessionsPage: number;
|
||||
sessionsPageSize: number;
|
||||
sessionsActionsOpenKey: string | null;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
|
||||
@@ -231,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;
|
||||
@@ -239,12 +240,6 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsFilterLimit = "120";
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
@state() sessionsSearchQuery = "";
|
||||
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
|
||||
@state() sessionsSortDir: "asc" | "desc" = "desc";
|
||||
@state() sessionsPage = 0;
|
||||
@state() sessionsPageSize = 10;
|
||||
@state() sessionsActionsOpenKey: string | null = null;
|
||||
|
||||
@state() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@@ -469,6 +464,12 @@ export class OpenClawApp extends LitElement {
|
||||
return [active, ...rest];
|
||||
}
|
||||
|
||||
handleThemeToggleCollapse() {
|
||||
setTimeout(() => {
|
||||
this.themeOrder = this.buildThemeOrder(this.theme);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||
import { agentLogoUrl } from "../views/agents-utils.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
@@ -57,10 +56,10 @@ function extractImages(message: unknown): ImageBlock[] {
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
@@ -77,7 +76,6 @@ export function renderStreamingGroup(
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
basePath?: string,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
@@ -87,7 +85,7 @@ export function renderStreamingGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
${renderAvatar("assistant", assistant)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
@@ -114,7 +112,6 @@ export function renderMessageGroup(
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
basePath?: string;
|
||||
onDelete?: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -135,14 +132,10 @@ export function renderMessageGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
)}
|
||||
${renderAvatar(group.role, {
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
renderGroupedMessage(
|
||||
@@ -173,11 +166,7 @@ export function renderMessageGroup(
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAvatar(
|
||||
role: string,
|
||||
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||
basePath?: string,
|
||||
) {
|
||||
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
@@ -206,28 +195,9 @@ function renderAvatar(
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
/* Use OpenClaw logo instead of emoji (e.g. ✨) for assistant avatar */
|
||||
const logoUrl = basePath ? agentLogoUrl(basePath) : "";
|
||||
if (logoUrl) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${logoUrl}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||
}
|
||||
|
||||
/* Assistant with no custom avatar: use logo when basePath available */
|
||||
if (normalized === "assistant" && basePath) {
|
||||
const logoUrl = agentLogoUrl(basePath);
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${logoUrl}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export class DashboardHeader extends LitElement {
|
||||
class="dashboard-header__breadcrumb-link"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
|
||||
>
|
||||
OpenClaw
|
||||
ClawDash
|
||||
</span>
|
||||
<span class="dashboard-header__breadcrumb-sep">›</span>
|
||||
<span class="dashboard-header__breadcrumb-current">${label}</span>
|
||||
|
||||
@@ -2,75 +2,70 @@ import { describe, expect, it } from "vitest";
|
||||
import { formatRelativeTimestamp, stripThinkingTags } from "./format.ts";
|
||||
|
||||
describe("formatAgo", () => {
|
||||
it("formats relative timestamps across future/past/null cases", () => {
|
||||
const now = Date.now();
|
||||
const cases = [
|
||||
{ name: "<1m future", input: now + 30_000, expected: "in <1m" },
|
||||
{ name: "minutes future", input: now + 5 * 60_000, expected: "in 5m" },
|
||||
{ name: "hours future", input: now + 3 * 60 * 60_000, expected: "in 3h" },
|
||||
{ name: "days future", input: now + 3 * 24 * 60 * 60_000, expected: "in 3d" },
|
||||
{ name: "recent past", input: now - 10_000, expected: "just now" },
|
||||
{ name: "minutes past", input: now - 5 * 60_000, expected: "5m ago" },
|
||||
{ name: "null", input: null, expected: "n/a" },
|
||||
{ name: "undefined", input: undefined, expected: "n/a" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(formatRelativeTimestamp(testCase.input), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
it("returns 'in <1m' for timestamps less than 60s in the future", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() + 30_000)).toBe("in <1m");
|
||||
});
|
||||
|
||||
it("returns 'Xm from now' for future timestamps", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() + 5 * 60_000)).toBe("in 5m");
|
||||
});
|
||||
|
||||
it("returns 'Xh from now' for future timestamps", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() + 3 * 60 * 60_000)).toBe("in 3h");
|
||||
});
|
||||
|
||||
it("returns 'Xd from now' for future timestamps beyond 48h", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() + 3 * 24 * 60 * 60_000)).toBe("in 3d");
|
||||
});
|
||||
|
||||
it("returns 'Xs ago' for recent past timestamps", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() - 10_000)).toBe("just now");
|
||||
});
|
||||
|
||||
it("returns 'Xm ago' for past timestamps", () => {
|
||||
expect(formatRelativeTimestamp(Date.now() - 5 * 60_000)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns 'n/a' for null/undefined", () => {
|
||||
expect(formatRelativeTimestamp(null)).toBe("n/a");
|
||||
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripThinkingTags", () => {
|
||||
it("normalizes think/final tag variants", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "strip think block",
|
||||
input: ["<think>", "secret", "</think>", "", "Hello"].join("\n"),
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "strip thinking block",
|
||||
input: ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n"),
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "unpaired think start",
|
||||
input: "<think>\nsecret\nHello",
|
||||
expected: "secret\nHello",
|
||||
},
|
||||
{
|
||||
name: "unpaired think end",
|
||||
input: "Hello\n</think>",
|
||||
expected: "Hello\n",
|
||||
},
|
||||
{
|
||||
name: "no tags",
|
||||
input: "Hello",
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "strip final block",
|
||||
input: "<final>\n\nHello there\n\n</final>",
|
||||
expected: "Hello there\n\n",
|
||||
},
|
||||
{
|
||||
name: "strip mixed think/final",
|
||||
input: "<think>reasoning</think>\n\n<final>Hello</final>",
|
||||
expected: "Hello",
|
||||
},
|
||||
{
|
||||
name: "incomplete final start",
|
||||
input: "<final\nHello",
|
||||
expected: "<final\nHello",
|
||||
},
|
||||
{
|
||||
name: "orphan final end",
|
||||
input: "Hello</final>",
|
||||
expected: "Hello",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(stripThinkingTags(testCase.input), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
it("strips <think>…</think> segments", () => {
|
||||
const input = ["<think>", "secret", "</think>", "", "Hello"].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("strips <thinking>…</thinking> segments", () => {
|
||||
const input = ["<thinking>", "secret", "</thinking>", "", "Hello"].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("keeps text when tags are unpaired", () => {
|
||||
expect(stripThinkingTags("<think>\nsecret\nHello")).toBe("secret\nHello");
|
||||
expect(stripThinkingTags("Hello\n</think>")).toBe("Hello\n");
|
||||
});
|
||||
|
||||
it("returns original text when no tags exist", () => {
|
||||
expect(stripThinkingTags("Hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("strips <final>…</final> segments", () => {
|
||||
const input = "<final>\n\nHello there\n\n</final>";
|
||||
expect(stripThinkingTags(input)).toBe("Hello there\n\n");
|
||||
});
|
||||
|
||||
it("strips mixed <think> and <final> tags", () => {
|
||||
const input = "<think>reasoning</think>\n\n<final>Hello</final>";
|
||||
expect(stripThinkingTags(input)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("handles incomplete <final tag gracefully", () => {
|
||||
// When streaming splits mid-tag, we may see "<final" without closing ">"
|
||||
// This should not crash and should handle gracefully
|
||||
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
|
||||
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,31 +334,6 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
lobster: html`
|
||||
<svg viewBox="0 0 120 120" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="lob-g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z"
|
||||
fill="url(#lob-g)"
|
||||
/>
|
||||
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#lob-g)" />
|
||||
<path
|
||||
d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z"
|
||||
fill="url(#lob-g)"
|
||||
/>
|
||||
<path d="M45 15Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<path d="M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<circle cx="45" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="75" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
|
||||
</svg>
|
||||
`,
|
||||
refresh: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
@@ -394,21 +369,6 @@ export const icons = {
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
`,
|
||||
moreHorizontal: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="6" cy="12" r="1.5" />
|
||||
<circle cx="18" cy="12" r="1.5" />
|
||||
</svg>
|
||||
`,
|
||||
arrowUpDown: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
</svg>
|
||||
`,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -141,7 +141,7 @@ htmlEscapeRenderer.code = ({
|
||||
}: {
|
||||
text: string;
|
||||
lang?: string;
|
||||
escaped?: boolean;
|
||||
escaped: boolean;
|
||||
}) => {
|
||||
const langClass = lang ? ` class="language-${lang}"` : "";
|
||||
const safeText = escaped ? text : escapeHtml(text);
|
||||
|
||||
@@ -26,22 +26,17 @@ describe("iconForTab", () => {
|
||||
});
|
||||
|
||||
it("returns stable icons for known tabs", () => {
|
||||
const cases = [
|
||||
{ tab: "chat", icon: "messageSquare" },
|
||||
{ tab: "overview", icon: "barChart" },
|
||||
{ tab: "channels", icon: "link" },
|
||||
{ tab: "instances", icon: "radio" },
|
||||
{ tab: "sessions", icon: "fileText" },
|
||||
{ tab: "cron", icon: "loader" },
|
||||
{ tab: "skills", icon: "zap" },
|
||||
{ tab: "nodes", icon: "monitor" },
|
||||
{ tab: "config", icon: "settings" },
|
||||
{ tab: "debug", icon: "bug" },
|
||||
{ tab: "logs", icon: "scrollText" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(iconForTab(testCase.tab), testCase.tab).toBe(testCase.icon);
|
||||
}
|
||||
expect(iconForTab("chat")).toBe("messageSquare");
|
||||
expect(iconForTab("overview")).toBe("barChart");
|
||||
expect(iconForTab("channels")).toBe("link");
|
||||
expect(iconForTab("instances")).toBe("radio");
|
||||
expect(iconForTab("sessions")).toBe("fileText");
|
||||
expect(iconForTab("cron")).toBe("loader");
|
||||
expect(iconForTab("skills")).toBe("zap");
|
||||
expect(iconForTab("nodes")).toBe("monitor");
|
||||
expect(iconForTab("config")).toBe("settings");
|
||||
expect(iconForTab("debug")).toBe("bug");
|
||||
expect(iconForTab("logs")).toBe("scrollText");
|
||||
});
|
||||
|
||||
it("returns a fallback icon for unknown tab", () => {
|
||||
@@ -61,14 +56,9 @@ describe("titleForTab", () => {
|
||||
});
|
||||
|
||||
it("returns expected titles", () => {
|
||||
const cases = [
|
||||
{ tab: "chat", title: "Chat" },
|
||||
{ tab: "overview", title: "Overview" },
|
||||
{ tab: "cron", title: "Cron Jobs" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(titleForTab(testCase.tab), testCase.tab).toBe(testCase.title);
|
||||
}
|
||||
expect(titleForTab("chat")).toBe("Chat");
|
||||
expect(titleForTab("overview")).toBe("Overview");
|
||||
expect(titleForTab("cron")).toBe("Cron Jobs");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,96 +77,108 @@ describe("subtitleForTab", () => {
|
||||
});
|
||||
|
||||
describe("normalizeBasePath", () => {
|
||||
it("normalizes base-path variants", () => {
|
||||
const cases = [
|
||||
{ input: "", expected: "" },
|
||||
{ input: "ui", expected: "/ui" },
|
||||
{ input: "/ui/", expected: "/ui" },
|
||||
{ input: "/", expected: "" },
|
||||
{ input: "/apps/openclaw", expected: "/apps/openclaw" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(normalizeBasePath(testCase.input), testCase.input).toBe(testCase.expected);
|
||||
}
|
||||
it("returns empty string for falsy input", () => {
|
||||
expect(normalizeBasePath("")).toBe("");
|
||||
});
|
||||
|
||||
it("adds leading slash if missing", () => {
|
||||
expect(normalizeBasePath("ui")).toBe("/ui");
|
||||
});
|
||||
|
||||
it("removes trailing slash", () => {
|
||||
expect(normalizeBasePath("/ui/")).toBe("/ui");
|
||||
});
|
||||
|
||||
it("returns empty string for root path", () => {
|
||||
expect(normalizeBasePath("/")).toBe("");
|
||||
});
|
||||
|
||||
it("handles nested paths", () => {
|
||||
expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("normalizes paths", () => {
|
||||
const cases = [
|
||||
{ input: "", expected: "/" },
|
||||
{ input: "chat", expected: "/chat" },
|
||||
{ input: "/chat/", expected: "/chat" },
|
||||
{ input: "/", expected: "/" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(normalizePath(testCase.input), testCase.input).toBe(testCase.expected);
|
||||
}
|
||||
it("returns / for falsy input", () => {
|
||||
expect(normalizePath("")).toBe("/");
|
||||
});
|
||||
|
||||
it("adds leading slash if missing", () => {
|
||||
expect(normalizePath("chat")).toBe("/chat");
|
||||
});
|
||||
|
||||
it("removes trailing slash except for root", () => {
|
||||
expect(normalizePath("/chat/")).toBe("/chat");
|
||||
expect(normalizePath("/")).toBe("/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathForTab", () => {
|
||||
it("builds tab paths with optional bases", () => {
|
||||
const cases = [
|
||||
{ tab: "chat", base: undefined, expected: "/chat" },
|
||||
{ tab: "overview", base: undefined, expected: "/overview" },
|
||||
{ tab: "chat", base: "/ui", expected: "/ui/chat" },
|
||||
{ tab: "sessions", base: "/apps/openclaw", expected: "/apps/openclaw/sessions" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
pathForTab(testCase.tab, testCase.base),
|
||||
`${testCase.tab}:${testCase.base ?? "root"}`,
|
||||
).toBe(testCase.expected);
|
||||
}
|
||||
it("returns correct path without base", () => {
|
||||
expect(pathForTab("chat")).toBe("/chat");
|
||||
expect(pathForTab("overview")).toBe("/overview");
|
||||
});
|
||||
|
||||
it("prepends base path", () => {
|
||||
expect(pathForTab("chat", "/ui")).toBe("/ui/chat");
|
||||
expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabFromPath", () => {
|
||||
it("resolves tabs from path variants", () => {
|
||||
const cases = [
|
||||
{ path: "/chat", base: undefined, expected: "chat" },
|
||||
{ path: "/overview", base: undefined, expected: "overview" },
|
||||
{ path: "/sessions", base: undefined, expected: "sessions" },
|
||||
{ path: "/", base: undefined, expected: "chat" },
|
||||
{ path: "/ui/chat", base: "/ui", expected: "chat" },
|
||||
{ path: "/apps/openclaw/sessions", base: "/apps/openclaw", expected: "sessions" },
|
||||
{ path: "/unknown", base: undefined, expected: null },
|
||||
{ path: "/CHAT", base: undefined, expected: "chat" },
|
||||
{ path: "/Overview", base: undefined, expected: "overview" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
tabFromPath(testCase.path, testCase.base),
|
||||
`${testCase.path}:${testCase.base ?? "root"}`,
|
||||
).toBe(testCase.expected);
|
||||
}
|
||||
it("returns tab for valid path", () => {
|
||||
expect(tabFromPath("/chat")).toBe("chat");
|
||||
expect(tabFromPath("/overview")).toBe("overview");
|
||||
expect(tabFromPath("/sessions")).toBe("sessions");
|
||||
});
|
||||
|
||||
it("returns chat for root path", () => {
|
||||
expect(tabFromPath("/")).toBe("chat");
|
||||
});
|
||||
|
||||
it("handles base paths", () => {
|
||||
expect(tabFromPath("/ui/chat", "/ui")).toBe("chat");
|
||||
expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions");
|
||||
});
|
||||
|
||||
it("returns null for unknown path", () => {
|
||||
expect(tabFromPath("/unknown")).toBeNull();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(tabFromPath("/CHAT")).toBe("chat");
|
||||
expect(tabFromPath("/Overview")).toBe("overview");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBasePathFromPathname", () => {
|
||||
it("infers base-path variants from pathname", () => {
|
||||
const cases = [
|
||||
{ path: "/", expected: "" },
|
||||
{ path: "/chat", expected: "" },
|
||||
{ path: "/overview", expected: "" },
|
||||
{ path: "/ui/chat", expected: "/ui" },
|
||||
{ path: "/apps/openclaw/sessions", expected: "/apps/openclaw" },
|
||||
{ path: "/index.html", expected: "" },
|
||||
{ path: "/ui/index.html", expected: "/ui" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(inferBasePathFromPathname(testCase.path), testCase.path).toBe(testCase.expected);
|
||||
}
|
||||
it("returns empty string for root", () => {
|
||||
expect(inferBasePathFromPathname("/")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for direct tab path", () => {
|
||||
expect(inferBasePathFromPathname("/chat")).toBe("");
|
||||
expect(inferBasePathFromPathname("/overview")).toBe("");
|
||||
});
|
||||
|
||||
it("infers base path from nested paths", () => {
|
||||
expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui");
|
||||
expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw");
|
||||
});
|
||||
|
||||
it("handles index.html suffix", () => {
|
||||
expect(inferBasePathFromPathname("/index.html")).toBe("");
|
||||
expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TAB_GROUPS", () => {
|
||||
it("contains all expected groups", () => {
|
||||
const labels = TAB_GROUPS.map((g) => g.label.toLowerCase());
|
||||
for (const expected of ["chat", "control", "agent", "settings"]) {
|
||||
expect(labels).toContain(expected);
|
||||
}
|
||||
const labels = TAB_GROUPS.map((g) => g.label);
|
||||
expect(labels).toContain("Chat");
|
||||
expect(labels).toContain("Control");
|
||||
expect(labels).toContain("Agent");
|
||||
expect(labels).toContain("Settings");
|
||||
});
|
||||
|
||||
it("all tabs are unique", () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ export type UiSettings = {
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (180–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
locale?: string;
|
||||
};
|
||||
@@ -34,7 +33,6 @@ export function loadSettings(): UiSettings {
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
};
|
||||
|
||||
@@ -76,10 +74,6 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.splitRatio,
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
|
||||
navWidth:
|
||||
typeof parsed.navWidth === "number" && parsed.navWidth >= 180 && parsed.navWidth <= 400
|
||||
? parsed.navWidth
|
||||
: defaults.navWidth,
|
||||
navGroupsCollapsed:
|
||||
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "clawdash";
|
||||
export type ThemeMode = "dark" | "light" | "openknot" | "fieldmanual" | "openai" | "clawdash";
|
||||
export type ResolvedTheme = ThemeMode;
|
||||
|
||||
export const VALID_THEMES = new Set<ThemeMode>([
|
||||
@@ -6,6 +6,7 @@ export const VALID_THEMES = new Set<ThemeMode>([
|
||||
"light",
|
||||
"openknot",
|
||||
"fieldmanual",
|
||||
"openai",
|
||||
"clawdash",
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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,
|
||||
@@ -13,7 +17,6 @@ import type { AgentsPanel } from "./agents.ts";
|
||||
|
||||
export function renderAgentOverview(params: {
|
||||
agent: AgentsListResult["agents"][number];
|
||||
basePath: string;
|
||||
defaultId: string | null;
|
||||
configForm: Record<string, unknown> | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
@@ -33,6 +36,9 @@ export function renderAgentOverview(params: {
|
||||
agent,
|
||||
configForm,
|
||||
agentFilesList,
|
||||
agentIdentity,
|
||||
agentIdentityLoading,
|
||||
agentIdentityError,
|
||||
configLoading,
|
||||
configSaving,
|
||||
configDirty,
|
||||
@@ -59,9 +65,26 @@ export function renderAgentOverview(params: {
|
||||
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) => {
|
||||
@@ -86,6 +109,21 @@ export function renderAgentOverview(params: {
|
||||
<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>
|
||||
@@ -118,8 +156,8 @@ export function renderAgentOverview(params: {
|
||||
|
||||
<div class="agent-model-select" style="margin-top: 20px;">
|
||||
<div class="label">Model Selection</div>
|
||||
<div class="agent-model-fields">
|
||||
<label class="field">
|
||||
<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 ?? ""}
|
||||
@@ -139,7 +177,7 @@ export function renderAgentOverview(params: {
|
||||
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="field">
|
||||
<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;
|
||||
@@ -177,12 +215,11 @@ export function renderAgentOverview(params: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-model-actions">
|
||||
<button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
|
||||
<div class="row" style="justify-content: flex-end; gap: 8px;">
|
||||
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
|
||||
Reload Config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${configSaving || !configDirty}
|
||||
@click=${onConfigSave}
|
||||
|
||||
@@ -35,8 +35,8 @@ function renderAgentContextCard(context: AgentContext, subtitle: string) {
|
||||
<div>${context.identityName}</div>
|
||||
</div>
|
||||
<div class="agent-kv">
|
||||
<div class="label">Identity Avatar</div>
|
||||
<div>${context.identityAvatar}</div>
|
||||
<div class="label">Identity Emoji</div>
|
||||
<div>${context.identityEmoji}</div>
|
||||
</div>
|
||||
<div class="agent-kv">
|
||||
<div class="label">Skills Filter</div>
|
||||
|
||||
@@ -138,30 +138,6 @@ export function normalizeAgentLabel(agent: {
|
||||
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
|
||||
}
|
||||
|
||||
const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i;
|
||||
|
||||
export function resolveAgentAvatarUrl(
|
||||
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||
agentIdentity?: AgentIdentityResult | null,
|
||||
): string | null {
|
||||
const url =
|
||||
agentIdentity?.avatar?.trim() ??
|
||||
agent.identity?.avatarUrl?.trim() ??
|
||||
agent.identity?.avatar?.trim();
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
if (AVATAR_URL_RE.test(url)) {
|
||||
return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function agentLogoUrl(basePath: string): string {
|
||||
const base = basePath?.trim() ? basePath.replace(/\/$/, "") : "";
|
||||
return base ? `${base}/favicon.svg` : "/favicon.svg";
|
||||
}
|
||||
|
||||
function isLikelyEmoji(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
@@ -253,7 +229,7 @@ export type AgentContext = {
|
||||
workspace: string;
|
||||
model: string;
|
||||
identityName: string;
|
||||
identityAvatar: string;
|
||||
identityEmoji: string;
|
||||
skillsLabel: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
@@ -279,14 +255,14 @@ export function buildAgentContext(
|
||||
agent.name?.trim() ||
|
||||
config.entry?.name ||
|
||||
agent.id;
|
||||
const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—";
|
||||
const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-";
|
||||
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
|
||||
const skillCount = skillFilter?.length ?? null;
|
||||
return {
|
||||
workspace,
|
||||
model: modelLabel,
|
||||
identityName,
|
||||
identityAvatar,
|
||||
identityEmoji,
|
||||
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
|
||||
isDefault: Boolean(defaultId && agent.id === defaultId),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
renderAgentCron,
|
||||
} from "./agents-panels-status-files.ts";
|
||||
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
||||
import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
|
||||
import {
|
||||
agentAvatarHue,
|
||||
agentBadgeText,
|
||||
buildAgentContext,
|
||||
normalizeAgentLabel,
|
||||
resolveAgentEmoji,
|
||||
} from "./agents-utils.ts";
|
||||
|
||||
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
|
||||
@@ -59,7 +65,6 @@ export type AgentSkillsState = {
|
||||
};
|
||||
|
||||
export type AgentsProps = {
|
||||
basePath: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
@@ -73,6 +78,8 @@ export type AgentsProps = {
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
agentSkills: AgentSkillsState;
|
||||
sidebarFilter: string;
|
||||
onSidebarFilterChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectPanel: (panel: AgentsPanel) => void;
|
||||
@@ -106,6 +113,14 @@ 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;
|
||||
@@ -121,81 +136,65 @@ export function renderAgents(props: AgentsProps) {
|
||||
|
||||
return html`
|
||||
<div class="agents-layout">
|
||||
<section class="agents-toolbar">
|
||||
<div class="agents-toolbar-row">
|
||||
<span class="agents-toolbar-label">Agent</span>
|
||||
<div class="agents-control-row">
|
||||
<div class="agents-control-select">
|
||||
<select
|
||||
class="agents-select"
|
||||
.value=${selectedId ?? ""}
|
||||
?disabled=${props.loading || agents.length === 0}
|
||||
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${
|
||||
agents.length === 0
|
||||
? html`
|
||||
<option value="">No agents</option>
|
||||
`
|
||||
: agents.map(
|
||||
(agent) => html`
|
||||
<option value=${agent.id} ?selected=${agent.id === selectedId}>
|
||||
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
|
||||
</option>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="agents-control-actions">
|
||||
${
|
||||
selectedAgent
|
||||
? html`
|
||||
<div class="agent-actions-wrap">
|
||||
<button
|
||||
class="agent-actions-toggle"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
actionsMenuOpen = !actionsMenuOpen;
|
||||
}}
|
||||
>⋯</button>
|
||||
${
|
||||
actionsMenuOpen
|
||||
? html`
|
||||
<div class="agent-actions-menu">
|
||||
<button type="button" @click=${() => {
|
||||
void navigator.clipboard.writeText(selectedAgent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}>Copy agent ID</button>
|
||||
<button
|
||||
type="button"
|
||||
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
|
||||
@click=${() => {
|
||||
props.onSetDefault(selectedAgent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
${defaultId && selectedAgent.id === defaultId ? "Already default" : "Set as default"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
<section class="card agents-sidebar">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Agents</div>
|
||||
<div class="card-sub">${agents.length} configured.</div>
|
||||
</div>
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</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>`
|
||||
: nothing
|
||||
}
|
||||
<div class="agent-list" style="margin-top: 12px;">
|
||||
${
|
||||
filteredAgents.length === 0
|
||||
? html`
|
||||
<div class="muted">${sidebarFilter ? "No matching agents." : "No agents found."}</div>
|
||||
`
|
||||
: 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" 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>
|
||||
</div>
|
||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="agents-main">
|
||||
${
|
||||
@@ -207,12 +206,17 @@ export function renderAgents(props: AgentsProps) {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${renderAgentHeader(
|
||||
selectedAgent,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
props.onSetDefault,
|
||||
)}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
|
||||
${
|
||||
props.activePanel === "overview"
|
||||
? renderAgentOverview({
|
||||
agent: selectedAgent,
|
||||
basePath: props.basePath,
|
||||
defaultId,
|
||||
configForm: props.config.form,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
@@ -335,6 +339,73 @@ export function renderAgents(props: AgentsProps) {
|
||||
|
||||
let actionsMenuOpen = false;
|
||||
|
||||
function renderAgentHeader(
|
||||
agent: AgentsListResult["agents"][number],
|
||||
defaultId: string | null,
|
||||
agentIdentity: AgentIdentityResult | null,
|
||||
onSetDefault: (agentId: string) => void,
|
||||
) {
|
||||
const badge = agentBadgeText(agent.id, defaultId);
|
||||
const displayName = normalizeAgentLabel(agent);
|
||||
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
|
||||
const emoji = resolveAgentEmoji(agent, agentIdentity);
|
||||
const hue = agentAvatarHue(agent.id);
|
||||
const isDefault = Boolean(defaultId && agent.id === defaultId);
|
||||
|
||||
const copyId = () => {
|
||||
void navigator.clipboard.writeText(agent.id);
|
||||
actionsMenuOpen = false;
|
||||
};
|
||||
|
||||
return html`
|
||||
<section class="card agent-header">
|
||||
<div class="agent-header-main">
|
||||
<div class="agent-avatar agent-avatar--lg" style="--agent-hue: ${hue}">
|
||||
${emoji || displayName.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-title">${displayName}</div>
|
||||
<div class="card-sub">${subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-header-meta">
|
||||
<div class="mono">${agent.id}</div>
|
||||
<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,
|
||||
|
||||
@@ -53,85 +53,118 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
}
|
||||
|
||||
describe("chat view", () => {
|
||||
it("renders/hides compaction and fallback indicators across recency states", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
nowMs?: number;
|
||||
props: Partial<ChatProps>;
|
||||
selector: string;
|
||||
missing?: boolean;
|
||||
expectedText?: string;
|
||||
}> = [
|
||||
{
|
||||
name: "active compaction",
|
||||
props: {
|
||||
it("renders compacting indicator as a badge", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: true,
|
||||
startedAt: 1_000,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator--active",
|
||||
expectedText: "Compacting context...",
|
||||
},
|
||||
{
|
||||
name: "recent compaction complete",
|
||||
nowMs: 1_000,
|
||||
props: {
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--active");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Compacting context...");
|
||||
});
|
||||
|
||||
it("renders completion indicator shortly after compaction", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: false,
|
||||
startedAt: 900,
|
||||
completedAt: 900,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator--complete",
|
||||
expectedText: "Context compacted",
|
||||
},
|
||||
{
|
||||
name: "stale compaction hidden",
|
||||
nowMs: 10_000,
|
||||
props: {
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--complete");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Context compacted");
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hides stale compaction completion indicator", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
compactionStatus: {
|
||||
active: false,
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator",
|
||||
missing: true,
|
||||
},
|
||||
{
|
||||
name: "recent fallback active",
|
||||
nowMs: 1_000,
|
||||
props: {
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".compaction-indicator")).toBeNull();
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders fallback indicator shortly after fallback event", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: ["fireworks/minimax-m2p5: rate limit"],
|
||||
occurredAt: 900,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator--fallback",
|
||||
expectedText: "Fallback active: deepinfra/moonshotai/Kimi-K2.5",
|
||||
},
|
||||
{
|
||||
name: "stale fallback hidden",
|
||||
nowMs: 20_000,
|
||||
props: {
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const indicator = container.querySelector(".compaction-indicator--fallback");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback active: deepinfra/moonshotai/Kimi-K2.5");
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("hides stale fallback indicator", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(20_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
active: "deepinfra/moonshotai/Kimi-K2.5",
|
||||
attempts: [],
|
||||
occurredAt: 0,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator--fallback",
|
||||
missing: true,
|
||||
},
|
||||
{
|
||||
name: "recent fallback cleared",
|
||||
nowMs: 1_000,
|
||||
props: {
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".compaction-indicator--fallback")).toBeNull();
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("renders fallback-cleared indicator shortly after transition", () => {
|
||||
const container = document.createElement("div");
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
fallbackStatus: {
|
||||
phase: "cleared",
|
||||
selected: "fireworks/minimax-m2p5",
|
||||
@@ -140,26 +173,15 @@ describe("chat view", () => {
|
||||
attempts: [],
|
||||
occurredAt: 900,
|
||||
},
|
||||
},
|
||||
selector: ".compaction-indicator--fallback-cleared",
|
||||
expectedText: "Fallback cleared: fireworks/minimax-m2p5",
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
for (const testCase of cases) {
|
||||
const nowSpy =
|
||||
testCase.nowMs === undefined ? null : vi.spyOn(Date, "now").mockReturnValue(testCase.nowMs);
|
||||
const container = document.createElement("div");
|
||||
render(renderChat(createProps(testCase.props)), container);
|
||||
const indicator = container.querySelector(testCase.selector);
|
||||
if (testCase.missing) {
|
||||
expect(indicator, testCase.name).toBeNull();
|
||||
} else {
|
||||
expect(indicator, testCase.name).not.toBeNull();
|
||||
expect(indicator?.textContent, testCase.name).toContain(testCase.expectedText ?? "");
|
||||
}
|
||||
nowSpy?.mockRestore();
|
||||
}
|
||||
const indicator = container.querySelector(".compaction-indicator--fallback-cleared");
|
||||
expect(indicator).not.toBeNull();
|
||||
expect(indicator?.textContent).toContain("Fallback cleared: fireworks/minimax-m2p5");
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows a stop button when aborting is available", () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
|
||||
import { agentLogoUrl } from "./agents-utils.ts";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
|
||||
@@ -94,7 +93,6 @@ export type ChatProps = {
|
||||
onCloseSidebar?: () => void;
|
||||
onSplitRatioChange?: (ratio: number) => void;
|
||||
onChatScroll?: (event: Event) => void;
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
@@ -139,6 +137,9 @@ let slashMenuIndex = 0;
|
||||
let searchOpen = false;
|
||||
let searchQuery = "";
|
||||
let pinnedExpanded = false;
|
||||
let voiceActive = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let recognition: any = null;
|
||||
|
||||
function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
@@ -360,6 +361,52 @@ function tokenEstimate(draft: string): string | null {
|
||||
return `~${Math.ceil(draft.length / 4)} tokens`;
|
||||
}
|
||||
|
||||
function startVoice(props: ChatProps, requestUpdate: () => void): void {
|
||||
const SR =
|
||||
(window as unknown as Record<string, unknown>).webkitSpeechRecognition ??
|
||||
(window as unknown as Record<string, unknown>).SpeechRecognition;
|
||||
if (!SR) {
|
||||
return;
|
||||
}
|
||||
const rec = new (SR as new () => Record<string, unknown>)();
|
||||
rec.continuous = false;
|
||||
rec.interimResults = true;
|
||||
rec.lang = "en-US";
|
||||
rec.onresult = (event: Record<string, unknown>) => {
|
||||
let transcript = "";
|
||||
const results = (
|
||||
event as { results: { length: number; [i: number]: { 0: { transcript: string } } } }
|
||||
).results;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
transcript += results[i][0].transcript;
|
||||
}
|
||||
props.onDraftChange(transcript);
|
||||
};
|
||||
(rec as unknown as EventTarget).addEventListener("end", () => {
|
||||
voiceActive = false;
|
||||
recognition = null;
|
||||
requestUpdate();
|
||||
});
|
||||
(rec as unknown as EventTarget).addEventListener("error", () => {
|
||||
voiceActive = false;
|
||||
recognition = null;
|
||||
requestUpdate();
|
||||
});
|
||||
(rec as { start: () => void }).start();
|
||||
recognition = rec;
|
||||
voiceActive = true;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
function stopVoice(requestUpdate: () => void): void {
|
||||
if (recognition && typeof recognition.stop === "function") {
|
||||
recognition.stop();
|
||||
}
|
||||
recognition = null;
|
||||
voiceActive = false;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
function exportMarkdown(props: ChatProps): void {
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
if (history.length === 0) {
|
||||
@@ -385,7 +432,7 @@ function exportMarkdown(props: ChatProps): void {
|
||||
function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = props.assistantAvatar ?? props.assistantAvatarUrl;
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
const initials = name.slice(0, 2).toUpperCase();
|
||||
|
||||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
@@ -393,11 +440,11 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
${
|
||||
avatar
|
||||
? html`<img src=${avatar} alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo"><img src=${logoUrl} alt="OpenClaw" /></div>`
|
||||
: html`<div class="agent-chat__avatar">${initials}</div>`
|
||||
}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
|
||||
<span class="agent-chat__badge">${icons.spark} Ready to chat</span>
|
||||
</div>
|
||||
<p class="agent-chat__hint">
|
||||
Type a message below · <kbd>/</kbd> for commands
|
||||
@@ -557,6 +604,10 @@ export function renderChat(props: ChatProps) {
|
||||
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||
const tokens = tokenEstimate(props.draft);
|
||||
|
||||
const hasVoice =
|
||||
typeof (window as unknown as Record<string, unknown>).webkitSpeechRecognition !== "undefined" ||
|
||||
typeof (window as unknown as Record<string, unknown>).SpeechRecognition !== "undefined";
|
||||
|
||||
const placeholder = props.connected
|
||||
? hasAttachments
|
||||
? "Add a message or paste more images..."
|
||||
@@ -612,7 +663,7 @@ export function renderChat(props: ChatProps) {
|
||||
`;
|
||||
}
|
||||
if (item.kind === "reading-indicator") {
|
||||
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
|
||||
return renderReadingIndicatorGroup(assistantIdentity);
|
||||
}
|
||||
if (item.kind === "stream") {
|
||||
return renderStreamingGroup(
|
||||
@@ -620,7 +671,6 @@ export function renderChat(props: ChatProps) {
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
props.basePath,
|
||||
);
|
||||
}
|
||||
if (item.kind === "group") {
|
||||
@@ -632,7 +682,6 @@ export function renderChat(props: ChatProps) {
|
||||
showReasoning,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: assistantIdentity.avatar,
|
||||
basePath: props.basePath,
|
||||
onDelete: () => {
|
||||
deleted.delete(item.key);
|
||||
requestUpdate();
|
||||
@@ -723,12 +772,9 @@ export function renderChat(props: ChatProps) {
|
||||
const handleInput = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
adjustTextareaHeight(target);
|
||||
props.onDraftChange(target.value);
|
||||
updateSlashMenu(target.value, requestUpdate);
|
||||
inputHistory.reset();
|
||||
// onDraftChange must be last: requestUpdate() inside updateSlashMenu
|
||||
// uses the stale render-time props.draft, overwriting chatMessage.
|
||||
// Calling onDraftChange last ensures the correct DOM value wins.
|
||||
props.onDraftChange(target.value);
|
||||
};
|
||||
|
||||
return html`
|
||||
@@ -759,6 +805,8 @@ export function renderChat(props: ChatProps) {
|
||||
${renderSearchBar(requestUpdate)}
|
||||
${renderPinnedSection(props, pinned, requestUpdate)}
|
||||
|
||||
${renderAgentBar(props)}
|
||||
|
||||
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
|
||||
<div
|
||||
class="chat-main"
|
||||
@@ -879,16 +927,58 @@ export function renderChat(props: ChatProps) {
|
||||
${icons.paperclip}
|
||||
</button>
|
||||
|
||||
${nothing /* mic hidden for now */}
|
||||
${
|
||||
hasVoice
|
||||
? html`
|
||||
<button
|
||||
class="agent-chat__input-btn ${voiceActive ? "agent-chat__input-btn--active" : ""}"
|
||||
@click=${() => {
|
||||
if (voiceActive) {
|
||||
stopVoice(requestUpdate);
|
||||
} else {
|
||||
startVoice(props, requestUpdate);
|
||||
}
|
||||
}}
|
||||
title="Voice input"
|
||||
>
|
||||
${voiceActive ? icons.micOff : icons.mic}
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__toolbar-right">
|
||||
${nothing /* search hidden for now */}
|
||||
<button class="btn-ghost" @click=${() => {
|
||||
searchOpen = !searchOpen;
|
||||
if (!searchOpen) {
|
||||
searchQuery = "";
|
||||
}
|
||||
requestUpdate();
|
||||
}} title="Search (Cmd+F)">
|
||||
${icons.search}
|
||||
</button>
|
||||
<button class="btn-ghost" @click=${() => exportMarkdown(props)} title="Export" ?disabled=${props.messages.length === 0}>
|
||||
${icons.download}
|
||||
</button>
|
||||
${
|
||||
props.messages.length > 0
|
||||
? html`
|
||||
<span class="agent-chat__input-divider"></span>
|
||||
<button class="btn-ghost" @click=${() => props.onSend()} title="Compact" ?disabled=${!props.connected || props.sending}>
|
||||
${icons.refresh}
|
||||
</button>
|
||||
<button class="btn-ghost" @click=${props.onNewSession} title="New chat" ?disabled=${!props.connected || props.sending}>
|
||||
${icons.plus}
|
||||
</button>
|
||||
<button class="btn-ghost btn-ghost--danger" @click=${props.onClearHistory} title="Clear history" ?disabled=${!props.connected || props.sending}>
|
||||
${icons.trash}
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
${
|
||||
canAbort && isBusy
|
||||
@@ -920,6 +1010,83 @@ export function renderChat(props: ChatProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAgentBar(props: ChatProps) {
|
||||
const agents = props.agentsList?.agents ?? [];
|
||||
if (agents.length <= 1 && !props.sessions?.sessions?.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
// Filter sessions for current agent
|
||||
const agentSessions = (props.sessions?.sessions ?? []).filter((s) => {
|
||||
const key = s.key ?? "";
|
||||
return (
|
||||
key.includes(`:${props.currentAgentId}:`) || key.startsWith(`agent:${props.currentAgentId}:`)
|
||||
);
|
||||
});
|
||||
|
||||
return html`
|
||||
<div class="chat-agent-bar">
|
||||
<div class="chat-agent-bar__left">
|
||||
${
|
||||
agents.length > 1
|
||||
? html`
|
||||
<select
|
||||
class="chat-agent-select"
|
||||
.value=${props.currentAgentId}
|
||||
@change=${(e: Event) => props.onAgentChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${agents.map(
|
||||
(a) => html`
|
||||
<option value=${a.id} ?selected=${a.id === props.currentAgentId}>
|
||||
${a.identity?.name || a.name || a.id}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: html`<span class="chat-agent-bar__name">${agents[0]?.identity?.name || agents[0]?.name || props.currentAgentId}</span>`
|
||||
}
|
||||
${
|
||||
agentSessions.length > 0
|
||||
? html`
|
||||
<details class="chat-sessions-panel">
|
||||
<summary class="chat-sessions-summary">
|
||||
${icons.fileText}
|
||||
<span>Sessions (${agentSessions.length})</span>
|
||||
</summary>
|
||||
<div class="chat-sessions-list">
|
||||
${agentSessions.map(
|
||||
(s) => html`
|
||||
<button
|
||||
class="chat-session-item ${s.key === props.sessionKey ? "chat-session-item--active" : ""}"
|
||||
@click=${() => props.onSessionSelect?.(s.key)}
|
||||
>
|
||||
<span class="chat-session-item__name">${s.displayName || s.label || s.key}</span>
|
||||
<span class="chat-session-item__meta muted">${s.model ?? ""}</span>
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="chat-agent-bar__right">
|
||||
${
|
||||
props.onNavigateToAgent
|
||||
? html`
|
||||
<button class="btn-ghost btn-ghost--sm" @click=${() => props.onNavigateToAgent?.()} title="Agent settings">
|
||||
${icons.settings}
|
||||
</button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const CHAT_HISTORY_RENDER_LIMIT = 200;
|
||||
|
||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -34,25 +35,6 @@ function blurDigits(value: string): TemplateResult {
|
||||
return html`${unsafeHTML(blurred)}`;
|
||||
}
|
||||
|
||||
type StatCard = {
|
||||
kind: string;
|
||||
tab: string;
|
||||
label: string;
|
||||
value: string | TemplateResult;
|
||||
hint: string | TemplateResult;
|
||||
redacted?: boolean;
|
||||
};
|
||||
|
||||
function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
|
||||
return html`
|
||||
<button class="ov-card" data-kind=${card.kind} @click=${() => onNavigate(card.tab)}>
|
||||
<span class="ov-card__label">${card.label}</span>
|
||||
<span class="ov-card__value ${card.redacted ? "redacted" : ""}">${card.value}</span>
|
||||
<span class="ov-card__hint">${card.hint}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderOverviewCards(props: OverviewCardsProps) {
|
||||
const totals = props.usageResult?.totals;
|
||||
const totalCost = formatCost(totals?.totalCost);
|
||||
@@ -70,75 +52,75 @@ export function renderOverviewCards(props: OverviewCardsProps) {
|
||||
const cronJobCount = props.cronJobs.length;
|
||||
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length;
|
||||
|
||||
const cronValue =
|
||||
cronEnabled == null
|
||||
? t("common.na")
|
||||
: cronEnabled
|
||||
? `${cronJobCount} jobs`
|
||||
: t("common.disabled");
|
||||
|
||||
const cronHint =
|
||||
failedCronCount > 0
|
||||
? html`<span class="danger">${failedCronCount} failed</span>`
|
||||
: cronNext
|
||||
? t("overview.stats.cronNext", { time: formatNextRun(cronNext) })
|
||||
: "";
|
||||
|
||||
const cards: StatCard[] = [
|
||||
{
|
||||
kind: "cost",
|
||||
tab: "usage",
|
||||
label: t("overview.cards.cost"),
|
||||
value: redact(totalCost, props.redacted),
|
||||
hint: redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted),
|
||||
redacted: props.redacted,
|
||||
},
|
||||
{
|
||||
kind: "sessions",
|
||||
tab: "sessions",
|
||||
label: t("overview.stats.sessions"),
|
||||
value: String(sessionCount ?? t("common.na")),
|
||||
hint: t("overview.stats.sessionsHint"),
|
||||
},
|
||||
{
|
||||
kind: "skills",
|
||||
tab: "skills",
|
||||
label: t("overview.cards.skills"),
|
||||
value: `${enabledSkills}/${totalSkills}`,
|
||||
hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`,
|
||||
},
|
||||
{
|
||||
kind: "cron",
|
||||
tab: "cron",
|
||||
label: t("overview.stats.cron"),
|
||||
value: cronValue,
|
||||
hint: cronHint,
|
||||
},
|
||||
];
|
||||
|
||||
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
|
||||
|
||||
return html`
|
||||
<section class="ov-cards">
|
||||
${cards.map((c) => renderStatCard(c, props.onNavigate))}
|
||||
<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>
|
||||
|
||||
${
|
||||
sessions.length > 0
|
||||
props.sessionsResult && props.sessionsResult.sessions.length > 0
|
||||
? html`
|
||||
<section class="ov-recent">
|
||||
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
|
||||
<ul class="ov-recent__list">
|
||||
${sessions.map(
|
||||
<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`
|
||||
<li class="ov-recent__row ${props.redacted ? "redacted" : ""}">
|
||||
<span class="ov-recent__key">${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)}</span>
|
||||
<span class="ov-recent__model">${s.model ?? ""}</span>
|
||||
<span class="ov-recent__time">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
|
||||
</li>
|
||||
<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>
|
||||
`,
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -2,12 +2,6 @@ import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
|
||||
/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */
|
||||
function stripAnsi(text: string): string {
|
||||
/* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */
|
||||
return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
export type OverviewLogTailProps = {
|
||||
lines: string[];
|
||||
redacted: boolean;
|
||||
@@ -19,13 +13,6 @@ export function renderOverviewLogTail(props: OverviewLogTailProps) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const displayLines = props.redacted
|
||||
? "[log hidden]"
|
||||
: props.lines
|
||||
.slice(-50)
|
||||
.map((line) => stripAnsi(line))
|
||||
.join("\n");
|
||||
|
||||
return html`
|
||||
<details class="card ov-log-tail">
|
||||
<summary class="ov-expandable-toggle">
|
||||
@@ -41,7 +28,9 @@ export function renderOverviewLogTail(props: OverviewLogTailProps) {
|
||||
}}
|
||||
>${icons.loader}</span>
|
||||
</summary>
|
||||
<pre class="ov-log-tail-content ${props.redacted ? "redacted" : ""}">${displayLines}</pre>
|
||||
<pre class="ov-log-tail-content ${props.redacted ? "redacted" : ""}">${
|
||||
props.redacted ? "[log hidden]" : props.lines.slice(-50).join("\n")
|
||||
}</pre>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -353,8 +353,6 @@ export function renderOverview(props: OverviewProps) {
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="ov-section-divider"></div>
|
||||
|
||||
${renderOverviewCards({
|
||||
usageResult: props.usageResult,
|
||||
sessionsResult: props.sessionsResult,
|
||||
@@ -368,8 +366,6 @@ export function renderOverview(props: OverviewProps) {
|
||||
|
||||
${renderOverviewAttention({ items: props.attentionItems })}
|
||||
|
||||
<div class="ov-section-divider"></div>
|
||||
|
||||
<div class="ov-bottom-grid" style="margin-top: 18px;">
|
||||
${renderOverviewEventLog({
|
||||
events: props.eventLog,
|
||||
|
||||
@@ -23,18 +23,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
basePath: "",
|
||||
searchQuery: "",
|
||||
sortColumn: "updated",
|
||||
sortDir: "desc",
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
actionsOpenKey: null,
|
||||
onFiltersChange: () => undefined,
|
||||
onSearchChange: () => undefined,
|
||||
onSortChange: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onPageSizeChange: () => undefined,
|
||||
onActionsOpenChange: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onPatch: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { pathForTab } from "../navigation.ts";
|
||||
import { formatSessionTokens } from "../presenter.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||
@@ -14,23 +13,12 @@ export type SessionsProps = {
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
basePath: string;
|
||||
searchQuery: string;
|
||||
sortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
sortDir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actionsOpenKey: string | null;
|
||||
onFiltersChange: (next: {
|
||||
activeMinutes: string;
|
||||
limit: string;
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
}) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onActionsOpenChange: (key: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
onPatch: (
|
||||
key: string,
|
||||
@@ -53,7 +41,6 @@ const VERBOSE_LEVELS = [
|
||||
{ value: "full", label: "full" },
|
||||
] as const;
|
||||
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||
|
||||
function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) {
|
||||
@@ -120,110 +107,24 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
|
||||
return value;
|
||||
}
|
||||
|
||||
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
const key = (row.key ?? "").toLowerCase();
|
||||
const label = (row.label ?? "").toLowerCase();
|
||||
const kind = (row.kind ?? "").toLowerCase();
|
||||
const displayName = (row.displayName ?? "").toLowerCase();
|
||||
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(
|
||||
rows: GatewaySessionRow[],
|
||||
column: "key" | "kind" | "updated" | "tokens",
|
||||
dir: "asc" | "desc",
|
||||
): GatewaySessionRow[] {
|
||||
const cmp = dir === "asc" ? 1 : -1;
|
||||
return [...rows].toSorted((a, b) => {
|
||||
let diff = 0;
|
||||
switch (column) {
|
||||
case "key":
|
||||
diff = (a.key ?? "").localeCompare(b.key ?? "");
|
||||
break;
|
||||
case "kind":
|
||||
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
|
||||
break;
|
||||
case "updated": {
|
||||
const au = a.updatedAt ?? 0;
|
||||
const bu = b.updatedAt ?? 0;
|
||||
diff = au - bu;
|
||||
break;
|
||||
}
|
||||
case "tokens": {
|
||||
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
|
||||
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
|
||||
diff = at - bt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return diff * cmp;
|
||||
});
|
||||
}
|
||||
|
||||
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
|
||||
const start = page * pageSize;
|
||||
return rows.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
export function renderSessions(props: SessionsProps) {
|
||||
const rawRows = props.result?.sessions ?? [];
|
||||
const filtered = filterRows(rawRows, props.searchQuery);
|
||||
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
|
||||
const totalRows = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
|
||||
const page = Math.min(props.page, totalPages - 1);
|
||||
const paginated = paginateRows(sorted, page, props.pageSize);
|
||||
|
||||
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
|
||||
const isActive = props.sortColumn === col;
|
||||
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
|
||||
return html`
|
||||
<th
|
||||
data-sortable
|
||||
data-sort-dir=${isActive ? props.sortDir : ""}
|
||||
@click=${() => props.onSortChange(col, isActive ? nextDir : "desc")}
|
||||
>
|
||||
${label}
|
||||
<span class="data-table-sort-icon">${icons.arrowUpDown}</span>
|
||||
</th>
|
||||
`;
|
||||
};
|
||||
|
||||
const rows = props.result?.sessions ?? [];
|
||||
return html`
|
||||
${
|
||||
props.actionsOpenKey
|
||||
? html`
|
||||
<div
|
||||
class="data-table-overlay"
|
||||
@click=${() => props.onActionsOpenChange(null)}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<section class="card" style=${props.actionsOpenKey ? "position: relative; z-index: 41;" : ""}>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Sessions</div>
|
||||
<div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
|
||||
<div class="card-sub">Active session keys and per-session overrides.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-bottom: 12px;">
|
||||
<label class="field-inline">
|
||||
<span>Active</span>
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field">
|
||||
<span>Active within (minutes)</span>
|
||||
<input
|
||||
style="width: 72px;"
|
||||
placeholder="min"
|
||||
.value=${props.activeMinutes}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -234,10 +135,9 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field-inline">
|
||||
<label class="field">
|
||||
<span>Limit</span>
|
||||
<input
|
||||
style="width: 64px;"
|
||||
.value=${props.limit}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -248,7 +148,8 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field-inline checkbox">
|
||||
<label class="field checkbox">
|
||||
<span>Include global</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeGlobal}
|
||||
@@ -260,9 +161,9 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
<span>Global</span>
|
||||
</label>
|
||||
<label class="field-inline checkbox">
|
||||
<label class="field checkbox">
|
||||
<span>Include unknown</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeUnknown}
|
||||
@@ -274,102 +175,39 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
<span>Unknown</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-toolbar">
|
||||
<div class="data-table-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by key, label, kind…"
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" style="margin-top: 12px;">
|
||||
${props.result ? `Store: ${props.result.path}` : ""}
|
||||
</div>
|
||||
|
||||
<div class="data-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${sortHeader("key", "Key")}
|
||||
<th>Label</th>
|
||||
${sortHeader("kind", "Kind")}
|
||||
${sortHeader("updated", "Updated")}
|
||||
${sortHeader("tokens", "Tokens")}
|
||||
<th>Thinking</th>
|
||||
<th>Verbose</th>
|
||||
<th>Reasoning</th>
|
||||
<th style="width: 60px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${
|
||||
paginated.length === 0
|
||||
? html`
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; padding: 48px 16px; color: var(--muted)">
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: paginated.map((row) =>
|
||||
renderRow(
|
||||
row,
|
||||
props.basePath,
|
||||
props.onPatch,
|
||||
props.onDelete,
|
||||
props.onActionsOpenChange,
|
||||
props.actionsOpenKey,
|
||||
props.loading,
|
||||
),
|
||||
)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table" style="margin-top: 16px;">
|
||||
<div class="table-head">
|
||||
<div>Key</div>
|
||||
<div>Label</div>
|
||||
<div>Kind</div>
|
||||
<div>Updated</div>
|
||||
<div>Tokens</div>
|
||||
<div>Thinking</div>
|
||||
<div>Verbose</div>
|
||||
<div>Reasoning</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
totalRows > 0
|
||||
rows.length === 0
|
||||
? html`
|
||||
<div class="data-table-pagination">
|
||||
<div class="data-table-pagination__info">
|
||||
${page * props.pageSize + 1}-${Math.min((page + 1) * props.pageSize, totalRows)}
|
||||
of ${totalRows} row${totalRows === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div class="data-table-pagination__controls">
|
||||
<select
|
||||
style="height: 32px; padding: 0 8px; font-size: 13px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card);"
|
||||
.value=${String(props.pageSize)}
|
||||
@change=${(e: Event) =>
|
||||
props.onPageSizeChange(Number((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
${PAGE_SIZES.map((s) => html`<option value=${s}>${s} per page</option>`)}
|
||||
</select>
|
||||
<button
|
||||
?disabled=${page <= 0}
|
||||
@click=${() => props.onPageChange(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
?disabled=${page >= totalPages - 1}
|
||||
@click=${() => props.onPageChange(page + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted">No sessions found.</div>
|
||||
`
|
||||
: nothing
|
||||
: rows.map((row) =>
|
||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -381,8 +219,6 @@ function renderRow(
|
||||
basePath: string,
|
||||
onPatch: SessionsProps["onPatch"],
|
||||
onDelete: SessionsProps["onDelete"],
|
||||
onActionsOpenChange: (key: string | null) => void,
|
||||
actionsOpenKey: string | null,
|
||||
disabled: boolean,
|
||||
) {
|
||||
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||
@@ -398,58 +234,36 @@ function renderRow(
|
||||
typeof row.displayName === "string" && row.displayName.trim().length > 0
|
||||
? row.displayName.trim()
|
||||
: null;
|
||||
const showDisplayName = Boolean(
|
||||
displayName &&
|
||||
displayName !== row.key &&
|
||||
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
|
||||
);
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label);
|
||||
const canLink = row.kind !== "global";
|
||||
const chatUrl = canLink
|
||||
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
||||
: null;
|
||||
const isMenuOpen = actionsOpenKey === row.key;
|
||||
const badgeClass =
|
||||
row.kind === "direct"
|
||||
? "data-table-badge--direct"
|
||||
: row.kind === "group"
|
||||
? "data-table-badge--group"
|
||||
: row.kind === "global"
|
||||
? "data-table-badge--global"
|
||||
: "data-table-badge--unknown";
|
||||
|
||||
return html`
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono session-key-cell">
|
||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
||||
${
|
||||
showDisplayName
|
||||
? html`<span class="muted session-key-display-name">${displayName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="table-row">
|
||||
<div class="mono session-key-cell">
|
||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
||||
${showDisplayName ? html`<span class="muted session-key-display-name">${displayName}</span>` : nothing}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
.value=${row.label ?? ""}
|
||||
?disabled=${disabled}
|
||||
placeholder="(optional)"
|
||||
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
|
||||
</td>
|
||||
<td>${updated}</td>
|
||||
<td>${formatSessionTokens(row)}</td>
|
||||
<td>
|
||||
</div>
|
||||
<div>${row.kind}</div>
|
||||
<div>${updated}</div>
|
||||
<div>${formatSessionTokens(row)}</div>
|
||||
<div>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, {
|
||||
@@ -464,11 +278,10 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { verboseLevel: value || null });
|
||||
@@ -481,11 +294,10 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { reasoningLevel: value || null });
|
||||
@@ -498,53 +310,12 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<div class="data-table-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="data-table-row-actions__trigger"
|
||||
aria-label="Open menu"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
onActionsOpenChange(isMenuOpen ? null : row.key);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
${
|
||||
isMenuOpen
|
||||
? html`
|
||||
<div class="data-table-row-actions__menu">
|
||||
${
|
||||
canLink
|
||||
? html`
|
||||
<a
|
||||
href=${chatUrl}
|
||||
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
|
||||
@click=${() => onActionsOpenChange(null)}
|
||||
>
|
||||
Open in Chat
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click=${() => {
|
||||
onActionsOpenChange(null);
|
||||
onDelete(row.key);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,19 @@ export function renderSkills(props: SkillsProps) {
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<label class="field" style="flex: 1; min-width: 180px;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Skills</div>
|
||||
<div class="card-sub">Bundled, managed, and workspace skills.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field" style="flex: 1;">
|
||||
<span>Filter</span>
|
||||
<input
|
||||
.value=${props.filter}
|
||||
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
||||
@@ -46,9 +57,6 @@ export function renderSkills(props: SkillsProps) {
|
||||
/>
|
||||
</label>
|
||||
<div class="muted">${filtered.length} shown</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -158,7 +158,6 @@ function renderDailyChartCompact(
|
||||
return html`
|
||||
<div class="daily-chart-compact">
|
||||
<div class="daily-chart-header">
|
||||
<div class="card-title" style="margin: 0;">Daily ${isTokenMode ? "Token" : "Cost"} Usage</div>
|
||||
<div class="chart-toggle small sessions-toggle">
|
||||
<button
|
||||
class="toggle-btn ${dailyChartMode === "total" ? "active" : ""}"
|
||||
@@ -167,12 +166,13 @@ function renderDailyChartCompact(
|
||||
Total
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}
|
||||
class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}"
|
||||
@click=${() => onDailyChartModeChange("by-type")}
|
||||
>
|
||||
By Type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title">Daily ${isTokenMode ? "Token" : "Cost"} Usage</div>
|
||||
</div>
|
||||
<div class="daily-chart">
|
||||
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export const usageStylesPart1 = `
|
||||
.usage-page-header {
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.usage-page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.usage-page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
/* ===== FILTERS & HEADER ===== */
|
||||
.usage-filters-inline {
|
||||
display: flex;
|
||||
|
||||
@@ -116,21 +116,21 @@ export const usageStylesPart2 = `
|
||||
.daily-chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ===== DAILY BAR CHART ===== */
|
||||
.daily-chart {
|
||||
margin-top: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.daily-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 200px;
|
||||
gap: 4px;
|
||||
padding: 4px 4px 30px;
|
||||
padding: 8px 4px 36px;
|
||||
}
|
||||
.daily-bar-wrapper {
|
||||
flex: 1;
|
||||
@@ -666,21 +666,21 @@ export const usageStylesPart2 = `
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== TWO ROWS: Daily+Breakdown, Sessions (each scrollable) ===== */
|
||||
/* ===== TWO COLUMN LAYOUT ===== */
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.usage-grid-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.usage-grid-left,
|
||||
.usage-grid-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ===== LEFT CARD (Daily + Breakdown) ===== */
|
||||
@@ -697,6 +697,6 @@ export const usageStylesPart2 = `
|
||||
.usage-left-card .sessions-panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2,14 +2,14 @@ export const usageStylesPart3 = `
|
||||
|
||||
/* ===== COMPACT DAILY CHART ===== */
|
||||
.daily-chart-compact {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.daily-chart-compact .sessions-panel-title {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.daily-chart-compact .daily-chart-bars {
|
||||
height: 100px;
|
||||
padding-bottom: 18px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ===== COMPACT COST BREAKDOWN ===== */
|
||||
@@ -18,17 +18,13 @@ export const usageStylesPart3 = `
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.cost-breakdown-compact .cost-breakdown-header {
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cost-breakdown-compact .cost-breakdown-legend {
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.cost-breakdown-compact .cost-breakdown-total {
|
||||
margin-top: 6px;
|
||||
gap: 12px;
|
||||
}
|
||||
.cost-breakdown-compact .cost-breakdown-note {
|
||||
display: none;
|
||||
@@ -45,7 +41,7 @@ export const usageStylesPart3 = `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sessions-card-title {
|
||||
font-weight: 600;
|
||||
@@ -59,8 +55,8 @@ export const usageStylesPart3 = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 6px 0 8px;
|
||||
gap: 12px;
|
||||
margin: 8px 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -447,6 +447,11 @@ export function renderUsage(props: UsageProps) {
|
||||
return html`
|
||||
<style>${usageStylesString}</style>
|
||||
|
||||
<section class="usage-page-header">
|
||||
<div class="usage-page-title">Usage</div>
|
||||
<div class="usage-page-subtitle">See where tokens go, when sessions spike, and what drives cost.</div>
|
||||
</section>
|
||||
|
||||
<section class="card usage-header ${props.headerPinned ? "pinned" : ""}">
|
||||
<div class="usage-header-row">
|
||||
<div class="usage-header-title">
|
||||
|
||||
Reference in New Issue
Block a user