UI: polish dashboard — agents overview, chat toolbar, debug & login UX (#23553)

* UI: polish dashboard — agents overview, chat toolbar, debug simplification, login UX

* fix(ui): restore chat draft ordering, remove extra toolbar buttons

* UI: replace agent avatar fallback with lobster emoji

* style(ui): update layout styles for sidebar and shell, adjusting navigation widths for improved responsiveness

* feat(ui): implement sidebar resizing functionality and enhance navigation with new search and sorting features for sessions

* fix(ui): update references from ClawDash to OpenClaw in checklist and dashboard header

* style(ui): adjust sidebar minimum width and add responsive behavior for narrow states

* UI: minimal chat agent bar — remove sessions panel, strip chrome

* style(ui): update light theme colors and add ambient gradient for Luxe Cream & Coral

* UI: replace sparkle with OpenClaw lobster logo in chat

* style(ui): rename theme toggle to theme select and update related styles; adjust layout and spacing for agents and chat components

* style(ui): enhance agents panel layout with grid system, update toolbar styles, and refine usage chart presentation

* style(ui): adjust sessions table column width and refine agent model fields layout for better responsiveness

* style(ui): refine component styles for improved layout and responsiveness; adjust gradients, spacing, and element alignment across chat and agent interfaces

* ui: align chat-controls session container

* ui: enlarge agent controls for better touch targets

* ui: pass basePath to avatar renderer in grouped chat

* ui: formatting fixups from pre-commit hooks

* style(ui): update layout and spacing for chat controls; enhance select component styles and improve responsiveness

* UI: tighten chat header spacing and icon sizes

* UI: widen chat attachment gap

* style(ui): refine chat header layout and adjust icon sizes for improved visual consistency

* style(ui): enhance component styles and layout; introduce new inline field styles, update overview card design, and improve session filters for better usability

* style(ui): improve CSS formatting and consistency across components; adjust gradients, spacing, and layout for better readability and visual appeal

* fix(ui): correct rendering of empty state in overview cards by replacing 'nothing' with an empty string
This commit is contained in:
Val Alexander 2026-02-22 07:56:17 -06:00 committed by GitHub
parent e578e8379c
commit e697ec273a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1986 additions and 1499 deletions

View File

@ -22,7 +22,7 @@ Open the dashboard at `http://localhost:<port>` (or the gateway's configured UI
- [ ] Light - [ ] Light
- [ ] OpenKnot (Aurora) - [ ] OpenKnot (Aurora)
- [ ] Field Manual - [ ] Field Manual
- [ ] ClawDash (Chrome) - [ ] OpenClaw (Chrome)
- [ ] Glass components (cards, panels, inputs) render correctly per theme - [ ] Glass components (cards, panels, inputs) render correctly per theme
- [ ] Theme persists across page reload - [ ] Theme persists across page reload

View File

@ -21,6 +21,7 @@ export const en: TranslationMap = {
settings: "Settings", settings: "Settings",
expand: "Expand sidebar", expand: "Expand sidebar",
collapse: "Collapse sidebar", collapse: "Collapse sidebar",
resize: "Resize sidebar",
}, },
tabs: { tabs: {
agents: "Agents", agents: "Agents",
@ -38,19 +39,19 @@ export const en: TranslationMap = {
logs: "Logs", logs: "Logs",
}, },
subtitles: { subtitles: {
agents: "Manage agent workspaces, tools, and identities.", agents: "Workspaces, tools, identities.",
overview: "Gateway status, entry points, and a fast health read.", overview: "Status, entry points, health.",
channels: "Manage channels and settings.", channels: "Channels and settings.",
instances: "Presence beacons from connected clients and nodes.", instances: "Connected clients and nodes.",
sessions: "Inspect active sessions and adjust per-session defaults.", sessions: "Active sessions and defaults.",
usage: "Monitor API usage and costs.", usage: "API usage and costs.",
cron: "Schedule wakeups and recurring agent runs.", cron: "Wakeups and recurring runs.",
skills: "Manage skill availability and API key injection.", skills: "Skills and API keys.",
nodes: "Paired devices, capabilities, and command exposure.", nodes: "Paired devices and commands.",
chat: "Direct gateway chat session for quick interventions.", chat: "Gateway chat for quick interventions.",
config: "Edit ~/.openclaw/openclaw.json safely.", config: "Edit openclaw.json.",
debug: "Gateway snapshots, events, and manual RPC calls.", debug: "Snapshots, events, RPC.",
logs: "Live tail of the gateway file logs.", logs: "Live gateway logs.",
}, },
overview: { overview: {
access: { access: {
@ -140,7 +141,7 @@ export const en: TranslationMap = {
}, },
login: { login: {
subtitle: "Gateway Dashboard", subtitle: "Gateway Dashboard",
tokenPlaceholder: "paste gateway token", passwordPlaceholder: "optional",
}, },
chat: { chat: {
disconnected: "Disconnected from gateway.", disconnected: "Disconnected from gateway.",

View File

@ -21,6 +21,7 @@ export const pt_BR: TranslationMap = {
settings: "Configurações", settings: "Configurações",
expand: "Expandir barra lateral", expand: "Expandir barra lateral",
collapse: "Recolher barra lateral", collapse: "Recolher barra lateral",
resize: "Redimensionar barra lateral",
}, },
tabs: { tabs: {
agents: "Agentes", agents: "Agentes",
@ -38,19 +39,19 @@ export const pt_BR: TranslationMap = {
logs: "Logs", logs: "Logs",
}, },
subtitles: { subtitles: {
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.", agents: "Espaços, ferramentas, identidades.",
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.", overview: "Status, entrada, saúde.",
channels: "Gerenciar canais e configurações.", channels: "Canais e configurações.",
instances: "Beacons de presença de clientes e nós conectados.", instances: "Clientes e nós conectados.",
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.", sessions: "Sessões ativas e padrões.",
usage: "Monitorar uso e custos da API.", usage: "Uso e custos da API.",
cron: "Agendar despertares e execuções recorrentes de agentes.", cron: "Despertares e execuções.",
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.", skills: "Habilidades e chaves API.",
nodes: "Dispositivos pareados, capacidades e exposição de comandos.", nodes: "Dispositivos e comandos.",
chat: "Sessão de chat direta com o gateway para intervenções rápidas.", chat: "Chat do gateway para intervenções rápidas.",
config: "Editar ~/.openclaw/openclaw.json com segurança.", config: "Editar openclaw.json.",
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.", debug: "Snapshots, eventos, RPC.",
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.", logs: "Logs ao vivo do gateway.",
}, },
overview: { overview: {
access: { access: {
@ -142,7 +143,7 @@ export const pt_BR: TranslationMap = {
}, },
login: { login: {
subtitle: "Painel do Gateway", subtitle: "Painel do Gateway",
tokenPlaceholder: "cole o token do gateway", passwordPlaceholder: "opcional",
}, },
chat: { chat: {
disconnected: "Desconectado do gateway.", disconnected: "Desconectado do gateway.",

View File

@ -21,6 +21,7 @@ export const zh_CN: TranslationMap = {
settings: "设置", settings: "设置",
expand: "展开侧边栏", expand: "展开侧边栏",
collapse: "折叠侧边栏", collapse: "折叠侧边栏",
resize: "调整侧边栏大小",
}, },
tabs: { tabs: {
agents: "代理", agents: "代理",
@ -38,19 +39,19 @@ export const zh_CN: TranslationMap = {
logs: "日志", logs: "日志",
}, },
subtitles: { subtitles: {
agents: "管理代理工作区、工具和身份。", agents: "工作区、工具、身份。",
overview: "网关状态、入口点和快速健康读取。", overview: "状态、入口点、健康。",
channels: "管理频道和设置。", channels: "频道和设置。",
instances: "来自已连接客户端和节点的在线信号。", instances: "已连接客户端和节点。",
sessions: "检查活动会话并调整每个会话的默认设置。", sessions: "活动会话和默认设置。",
usage: "监控 API 使用情况和成本。", usage: "API 使用情况和成本。",
cron: "安排唤醒和重复的代理运行。", cron: "唤醒和重复运行。",
skills: "管理技能可用性和 API 密钥注入。", skills: "技能和 API 密钥。",
nodes: "配对设备、功能和命令公开。", nodes: "配对设备和命令。",
chat: "用于快速干预的直接网关聊天会话。", chat: "网关聊天,快速干预。",
config: "安全地编辑 ~/.openclaw/openclaw.json。", config: "编辑 openclaw.json。",
debug: "网关快照、事件和手动 RPC 调用。", debug: "快照、事件、RPC。",
logs: "网关文件日志的实时追踪。", logs: "实时网关日志。",
}, },
overview: { overview: {
access: { access: {
@ -139,7 +140,7 @@ export const zh_CN: TranslationMap = {
}, },
login: { login: {
subtitle: "网关仪表盘", subtitle: "网关仪表盘",
tokenPlaceholder: "粘贴网关令牌", passwordPlaceholder: "可选",
}, },
chat: { chat: {
disconnected: "已断开与网关的连接。", disconnected: "已断开与网关的连接。",

View File

@ -21,6 +21,7 @@ export const zh_TW: TranslationMap = {
settings: "設置", settings: "設置",
expand: "展開側邊欄", expand: "展開側邊欄",
collapse: "折疊側邊欄", collapse: "折疊側邊欄",
resize: "調整側邊欄大小",
}, },
tabs: { tabs: {
agents: "代理", agents: "代理",
@ -38,19 +39,19 @@ export const zh_TW: TranslationMap = {
logs: "日誌", logs: "日誌",
}, },
subtitles: { subtitles: {
agents: "管理代理工作區、工具和身份。", agents: "工作區、工具、身份。",
overview: "網關狀態、入口點和快速健康讀取。", overview: "狀態、入口點、健康。",
channels: "管理頻道和設置。", channels: "頻道和設置。",
instances: "來自已連接客戶端和節點的在線信號。", instances: "已連接客戶端和節點。",
sessions: "檢查活動會話並調整每個會話的默認設置。", sessions: "活動會話和默認設置。",
usage: "監控 API 使用情況和成本。", usage: "API 使用情況和成本。",
cron: "安排喚醒和重複的代理運行。", cron: "喚醒和重複運行。",
skills: "管理技能可用性和 API 密鑰注入。", skills: "技能和 API 密鑰。",
nodes: "配對設備、功能和命令公開。", nodes: "配對設備和命令。",
chat: "用於快速干預的直接網關聊天會話。", chat: "網關聊天,快速干預。",
config: "安全地編輯 ~/.openclaw/openclaw.json。", config: "編輯 openclaw.json。",
debug: "網關快照、事件和手動 RPC 調用。", debug: "快照、事件、RPC。",
logs: "網關文件日志的實時追蹤。", logs: "實時網關日誌。",
}, },
overview: { overview: {
access: { access: {
@ -139,7 +140,7 @@ export const zh_TW: TranslationMap = {
}, },
login: { login: {
subtitle: "閘道儀表板", subtitle: "閘道儀表板",
tokenPlaceholder: "貼上閘道令牌", passwordPlaceholder: "可選",
}, },
chat: { chat: {
disconnected: "已斷開與網關的連接。", disconnected: "已斷開與網關的連接。",

View File

@ -96,55 +96,55 @@
--radius-full: 9999px; --radius-full: 9999px;
} }
/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */ /* ─── Theme: light — Luxe Cream & Coral ─── */
:root[data-theme="light"] { :root[data-theme="light"] {
color-scheme: dark; color-scheme: light;
--vscode-bg: #0e0c0e; --vscode-bg: #faf7f2;
--vscode-sidebar: #131012; --vscode-sidebar: #f5f0e8;
--vscode-panel: #161214; --vscode-panel: #fffef9;
--vscode-panel-border: rgba(255, 255, 255, 0.06); --vscode-panel-border: rgba(26, 22, 20, 0.08);
--vscode-surface: #1a1618; --vscode-surface: #fffef9;
--vscode-hover: #201c1e; --vscode-hover: #f0ebe3;
--vscode-contrast: #080608; --vscode-contrast: #f0ebe3;
--vscode-text: #d5d0cf; --vscode-text: #1a1614;
--vscode-muted: #7a7472; --vscode-muted: #6b5d54;
--vscode-subtle: #4a4442; --vscode-subtle: #9c8f84;
--vscode-ghost: #1a1616; --vscode-ghost: #ebe6df;
--vscode-accent: #ca3a29; --vscode-accent: #c73526;
--vscode-accent-alpha: rgba(202, 58, 41, 0.14); --vscode-accent-alpha: rgba(199, 53, 38, 0.12);
--vscode-selection: #3d1418; --vscode-selection: rgba(199, 53, 38, 0.18);
--vscode-success: #00d4aa; --vscode-success: #0d9b7a;
--vscode-danger: #ca3a29; --vscode-danger: #c73526;
--kn-claw: #ca3a29; --kn-claw: #c73526;
--kn-claw-bright: #fd8e2e; --kn-claw-bright: #e85a4a;
--kn-claw-dim: rgba(202, 58, 41, 0.12); --kn-claw-dim: rgba(199, 53, 38, 0.14);
--kn-claw-ember: #fb9231; --kn-claw-ember: #d94a3a;
--kn-claw-deep: #9a2d1f; --kn-claw-deep: #9a2a1e;
--kn-ocean: #0e0c0e; --kn-ocean: #faf7f2;
--kn-ocean-bright: #201c1e; --kn-ocean-bright: #fffef9;
--kn-ocean-mid: #161214; --kn-ocean-mid: #f5f0e8;
--kn-ocean-dim: rgba(14, 12, 14, 0.8); --kn-ocean-dim: rgba(250, 247, 242, 0.9);
--kn-ocean-deep: #0e0c0e; --kn-ocean-deep: #f0ebe3;
--kn-silver: #8a7e72; --kn-silver: #6b5d54;
--kn-silver-bright: #c0b4a8; --kn-silver-bright: #1a1614;
--kn-silver-dim: rgba(138, 126, 114, 0.12); --kn-silver-dim: rgba(107, 93, 84, 0.12);
--kn-bioluminescence: #00d4aa; --kn-bioluminescence: #0d9b7a;
--kn-warm-dark: #1a1416; --kn-warm-dark: #1a1614;
--kn-void: #1a1416; --kn-void: #ebe6df;
--glass-blur: 0px; --glass-blur: 12px;
--glass-saturate: 100%; --glass-saturate: 110%;
--glass-bg: rgba(22, 18, 20, 0.95); --glass-bg: rgba(255, 254, 249, 0.88);
--glass-bg-elevated: rgba(26, 22, 24, 0.96); --glass-bg-elevated: rgba(255, 255, 255, 0.95);
--glass-border: rgba(255, 255, 255, 0.06); --glass-border: rgba(26, 22, 20, 0.1);
--glass-border-hover: rgba(202, 58, 41, 0.25); --glass-border-hover: rgba(199, 53, 38, 0.35);
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03); --glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); --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 24px rgba(0, 0, 0, 0.4); --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 16px 48px rgba(0, 0, 0, 0.5); --glass-shadow-lg: 0 20px 56px rgba(26, 22, 20, 0.12), 0 4px 16px rgba(26, 22, 20, 0.06);
--radius-xs: 4px; --radius-xs: 4px;
--radius-sm: 8px; --radius-sm: 8px;
@ -491,6 +491,16 @@
--agent-tab-hover-bg: var(--vscode-accent-alpha); --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 ─── */ /* ─── Accessibility: High Contrast ─── */
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
@ -714,6 +724,20 @@ select {
display: none; display: none;
} }
/* ─── light — Luxe Cream ambient gradient ─── */
:root[data-theme="light"] 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%),
var(--bg);
}
:root[data-theme="light"] body::after {
display: none;
}
/* ─── clawdash — Chrome Metallic Overrides ─── */ /* ─── clawdash — Chrome Metallic Overrides ─── */
:root[data-theme="clawdash"] body { :root[data-theme="clawdash"] body {

View File

@ -107,9 +107,12 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.agent-chat__badge svg { .agent-chat__badge svg,
.agent-chat__badge img {
width: 14px; width: 14px;
height: 14px; height: 14px;
object-fit: contain;
vertical-align: -0.15em;
} }
/* ─── Starter Cards ─── */ /* ─── Starter Cards ─── */
@ -239,6 +242,17 @@
flex-shrink: 0; 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 { .agent-chat__avatar--sm {
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -1124,10 +1138,11 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 4px 12px; padding: 2px 8px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
flex-shrink: 0; flex-shrink: 0;
gap: 6px; gap: 4px;
min-height: 28px;
} }
.chat-agent-bar__left { .chat-agent-bar__left {
@ -1145,143 +1160,29 @@
} }
.chat-agent-bar__name { .chat-agent-bar__name {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
} }
.chat-agent-select { .chat-agent-select {
background: color-mix(in srgb, var(--secondary) 70%, transparent); background: transparent;
border: 1px solid var(--border); border: none;
border-radius: var(--radius-md);
color: var(--text); color: var(--text);
font-size: 12px; font-size: 11px;
font-weight: 500; font-weight: 600;
padding: 2px 20px 2px 6px; padding: 0 14px 0 0;
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' 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='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-repeat: no-repeat; background-repeat: no-repeat;
background-position: right 4px center; background-position: right 0 center;
transition:
border-color 150ms ease,
background 150ms ease;
} }
.chat-agent-select:hover { .chat-agent-select:hover {
border-color: var(--border-strong); color: var(--accent);
background: color-mix(in srgb, var(--secondary) 90%, transparent);
} }
.chat-agent-select:focus { .chat-agent-select:focus {
outline: none; 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: 4px;
padding: 2px 6px;
border-radius: var(--radius-sm);
font-size: 11px;
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: 12px;
height: 12px;
}
.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: 6px;
padding: 4px 8px;
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text);
font-size: 11px;
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;
} }

View File

@ -7,7 +7,7 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: flex-start; align-items: flex-start;
margin-bottom: 16px; margin-bottom: 8px;
margin-left: 4px; margin-left: 4px;
margin-right: 16px; margin-right: 16px;
} }
@ -124,6 +124,13 @@ img.chat-avatar {
object-position: center; 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 */ /* Minimal Bubble Design - dynamic width based on content */
.chat-bubble { .chat-bubble {
position: relative; position: relative;

View File

@ -9,7 +9,8 @@
flex-direction: column; flex-direction: column;
flex: 1 1 0; flex: 1 1 0;
height: 100%; height: 100%;
min-height: 0; /* Allow flex shrinking */ min-height: 0;
/* Allow flex shrinking */
overflow: hidden; overflow: hidden;
background: transparent !important; background: transparent !important;
border: none !important; border: none !important;
@ -21,18 +22,18 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
flex-wrap: nowrap; flex-wrap: nowrap;
flex-shrink: 0; flex-shrink: 0;
padding-bottom: 12px; padding-bottom: 4px;
margin-bottom: 12px; margin-bottom: 4px;
background: transparent; background: transparent;
} }
.chat-header__left { .chat-header__left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
min-width: 0; min-width: 0;
} }
@ -40,21 +41,23 @@
.chat-header__right { .chat-header__right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
} }
.chat-session { .chat-session {
min-width: 180px; min-width: 140px;
} }
/* Chat thread - scrollable middle section, transparent */ /* Chat thread - scrollable middle section, transparent */
.chat-thread { .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-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 10px 6px; padding: 6px 6px 4px;
margin: 0 -4px; margin: 0 -4px;
min-height: 0; /* Allow shrinking for flex scroll behavior */ min-height: 0;
/* Allow shrinking for flex scroll behavior */
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
@ -151,9 +154,10 @@
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
margin-top: auto; /* Push to bottom of flex container */ margin-top: auto;
padding: 14px 6px 6px; /* Push to bottom of flex container */
padding: 6px 6px 6px;
background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%); background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 94%, black) 22%);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
z-index: 10; z-index: 10;
@ -170,7 +174,8 @@
border: 1px solid var(--border); border: 1px solid var(--border);
width: fit-content; width: fit-content;
max-width: 100%; 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 { .chat-attachment {
@ -318,13 +323,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 6px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.chat-controls__session { .chat-controls__session {
min-width: 120px; min-width: 120px;
max-width: 260px; max-width: 220px;
} }
.chat-controls__thinking { .chat-controls__thinking {
@ -336,7 +341,7 @@
/* Icon button style */ /* Icon button style */
.btn--icon { .btn--icon {
padding: 6px !important; padding: 0 !important;
min-width: 32px; min-width: 32px;
height: 32px; height: 32px;
display: inline-flex; display: inline-flex;
@ -347,12 +352,17 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
/* Controls separator */ /* Controls separator — renders as a thin vertical divider */
.chat-controls__separator { .chat-controls__separator {
color: var(--border); width: 1px;
font-size: 14px; height: 32px;
margin: 0 2px; background: var(--border);
font-weight: 300; font-size: 0;
color: transparent;
overflow: hidden;
align-self: center;
flex-shrink: 0;
margin: 0 4px;
} }
.btn--icon:hover { .btn--icon:hover {
@ -371,6 +381,7 @@
display: block; display: block;
width: 16px; width: 16px;
height: 16px; height: 16px;
flex-shrink: 0;
stroke: currentColor; stroke: currentColor;
fill: none; fill: none;
stroke-width: 1.5px; stroke-width: 1.5px;
@ -379,9 +390,9 @@
} }
.chat-controls__session select { .chat-controls__session select {
padding: 4px 8px; padding: 0 28px 0 10px;
font-size: 12px; font-size: 13px;
max-width: 260px; max-width: 220px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@ -390,16 +401,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 11px; font-size: 12px;
padding: 2px 8px; padding: 0 10px;
height: 32px;
background: color-mix(in srgb, var(--secondary) 90%, transparent); background: color-mix(in srgb, var(--secondary) 90%, transparent);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); border: 1px solid color-mix(in srgb, var(--border) 88%, transparent);
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.chat-session { .chat-session {
min-width: 100px; min-width: 80px;
} }
.chat-compose { .chat-compose {
@ -408,11 +420,15 @@
.chat-controls { .chat-controls {
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 3px;
} }
.chat-controls__session { .chat-controls__session {
min-width: 100px; min-width: 80px;
max-width: 180px; max-width: 160px;
}
.chat-controls__separator {
display: none;
} }
} }

View File

@ -18,7 +18,8 @@
.chat-sidebar { .chat-sidebar {
flex: 1; flex: 1;
min-width: 300px; min-width: 200px;
container-type: inline-size;
border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent); border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -77,11 +78,14 @@
flex: 1; flex: 1;
overflow: auto; overflow: auto;
padding: 16px; padding: 16px;
min-width: 0;
} }
.sidebar-markdown { .sidebar-markdown {
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
overflow-wrap: break-word;
word-break: break-word;
} }
.sidebar-markdown pre { .sidebar-markdown pre {
@ -97,6 +101,38 @@
font-size: 13px; 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 */ /* Mobile: Full-screen modal */
@media (max-width: 768px) { @media (max-width: 768px) {
.chat-split-container--open { .chat-split-container--open {

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
grid-template-columns: 260px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr);
gap: 0; gap: 0;
height: calc(100vh - 160px); height: calc(100vh - 160px);
margin: 0 -16px -16px; margin: -16px;
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel); background: var(--panel);

View File

@ -3,10 +3,10 @@
=========================================== */ =========================================== */
.shell { .shell {
--shell-pad: 16px; --shell-pad: 12px;
--shell-gap: 16px; --shell-gap: 12px;
--shell-nav-width: 240px; --shell-nav-width: 220px;
--shell-topbar-height: 62px; --shell-topbar-height: 52px;
--shell-focus-duration: 200ms; --shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out); --shell-focus-ease: var(--ease-out);
height: 100vh; height: 100vh;
@ -80,8 +80,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 0 20px; padding: 0 16px;
height: var(--shell-topbar-height); height: var(--shell-topbar-height);
background: var(--topbar-bg); background: var(--topbar-bg);
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur)); backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
@ -102,7 +102,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-size: 0.82rem; font-size: 0.8rem;
min-width: 0; min-width: 0;
} }
@ -142,17 +142,17 @@
.topbar-search { .topbar-search {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding: 6px 12px; padding: 5px 10px;
min-width: 200px; min-width: 160px;
max-width: 340px; max-width: 280px;
flex: 1; flex: 1;
height: 34px; height: 30px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: color-mix(in srgb, var(--secondary) 60%, transparent); background: color-mix(in srgb, var(--secondary) 60%, transparent);
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
font-family: var(--font-body); font-family: var(--font-body);
cursor: pointer; cursor: pointer;
transition: transition:
@ -220,10 +220,10 @@
.topbar-connection { .topbar-connection {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
padding: 4px 10px; padding: 3px 8px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-size: 12px; font-size: 11px;
font-weight: 500; font-weight: 500;
color: var(--danger); color: var(--danger);
background: var(--danger-subtle); background: var(--danger-subtle);
@ -262,10 +262,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; width: 26px;
min-width: 28px; height: 26px;
height: 28px; padding: 0;
padding: 0 4px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
background: none; background: none;
@ -279,8 +278,8 @@
} }
.topbar-redact svg { .topbar-redact svg {
width: 14px; width: 12px;
height: 14px; height: 12px;
} }
.topbar-redact:hover { .topbar-redact:hover {
@ -290,45 +289,40 @@
} }
.topbar-redact--active { .topbar-redact--active {
border-radius: var(--radius-full);
padding: 4px 10px;
color: var(--warn); color: var(--warn);
background: var(--warn-subtle);
} }
.topbar-redact--active:hover { .topbar-redact--active:hover {
color: var(--warn); color: var(--warn);
background: color-mix(in srgb, var(--warn-subtle) 80%, var(--warn) 10%); background: var(--warn-subtle);
border-color: color-mix(in srgb, var(--warn) 30%, transparent);
} }
.topbar-redact__label { /* Topbar theme select sizing */
font-size: 12px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1;
white-space: nowrap;
}
/* Topbar theme toggle sizing */ .topbar-status .theme-select {
height: 26px;
.topbar-status .theme-toggle { min-width: 82px;
height: 30px; font-size: 11px;
}
.topbar-status .theme-btn svg {
width: 13px;
height: 13px;
} }
/* =========================================== /* ===========================================
Navigation Sidebar Navigation Sidebar
=========================================== */ =========================================== */
.sidebar { .shell-nav {
grid-area: nav; grid-area: nav;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
position: relative;
}
.sidebar {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: none; scrollbar-width: none;
@ -347,13 +341,9 @@
display: none; display: none;
} }
.shell--chat-focus .sidebar { .shell--chat-focus .sidebar,
width: 0; .shell--chat-focus .sidebar-resizer {
padding: 0; display: none;
border-width: 0;
overflow: hidden;
pointer-events: none;
opacity: 0;
} }
.sidebar--collapsed { .sidebar--collapsed {
@ -395,6 +385,21 @@
border-left: 0; 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 { .sidebar--collapsed .sidebar-footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -409,24 +414,54 @@
height: 44px; 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 (brand + collapse) */
.sidebar-header { .sidebar-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding: 10px 8px; padding: 10px 8px;
gap: 0; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
min-height: 54px; min-height: 54px;
} }
.sidebar-brand { .sidebar-brand {
flex: 2; flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-width: 0;
max-height: 28px; max-height: 28px;
padding-left: 10px; padding-left: 10px;
@ -452,13 +487,19 @@
line-height: 1.1; line-height: 1.1;
color: var(--text-strong); color: var(--text-strong);
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
.sidebar-collapse-btn { .sidebar-collapse-btn {
flex: 1; flex: 0 0 32px;
width: 32px;
height: 32px; height: 32px;
@media (max-width: 1100px) { @media (max-width: 1100px) {
flex: 0 0 28px;
width: 28px;
height: 28px; height: 28px;
} }
@ -595,6 +636,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
overflow: hidden;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
@ -667,6 +709,33 @@
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent); 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 { .nav-item--active .nav-item__icon {
opacity: 1; opacity: 1;
color: var(--accent); color: var(--accent);
@ -680,11 +749,17 @@
margin-top: auto; margin-top: auto;
} }
.sidebar-footer__docs-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-version { .sidebar-version {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
padding: 6px 10px; padding: 2px 12px 6px;
} }
.sidebar-version__text { .sidebar-version__text {
@ -708,7 +783,7 @@
.content { .content {
grid-area: content; grid-area: content;
padding: 14px 18px 36px; padding: 12px 14px 24px;
display: block; display: block;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@ -716,13 +791,13 @@
} }
.content > * + * { .content > * + * {
margin-top: 24px; margin-top: 18px;
} }
.content--chat { .content--chat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 2px;
overflow: hidden; overflow: hidden;
padding-bottom: 0; padding-bottom: 0;
} }
@ -734,10 +809,12 @@
/* Content header */ /* Content header */
.content-header { .content-header {
display: flex; display: flex;
align-items: flex-end; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
padding: 2px 0; height: 36px;
min-height: 36px;
padding: 0;
overflow: hidden; overflow: hidden;
transform-origin: top center; transform-origin: top center;
transition: transition:
@ -745,30 +822,30 @@
transform var(--shell-focus-duration) var(--shell-focus-ease), transform var(--shell-focus-duration) var(--shell-focus-ease),
max-height 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); padding var(--shell-focus-duration) var(--shell-focus-ease);
max-height: 64px; max-height: 36px;
} }
.shell--chat-focus .content-header { .shell--chat-focus .content-header {
opacity: 0; opacity: 0;
transform: translateY(-8px); transform: translateY(-8px);
max-height: 0px; max-height: 0;
padding: 0; padding: 0;
pointer-events: none; pointer-events: none;
} }
.page-title { .page-title {
font-size: 22px; font-size: 18px;
font-weight: 600; font-weight: 600;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.2; line-height: 1.25;
color: var(--text-strong); color: var(--text-strong);
} }
.page-sub { .page-sub {
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 12px;
font-weight: 400; font-weight: 400;
margin-top: 2px; margin-top: 1px;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
@ -783,10 +860,13 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
} }
.content--chat .content-header > div:first-child { .content--chat .content-header > div:first-child {
display: flex;
flex-direction: column;
justify-content: center;
text-align: left; text-align: left;
} }
@ -796,6 +876,66 @@
.content--chat .chat-controls { .content--chat .chat-controls {
flex-shrink: 0; 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;
} }
/* =========================================== /* ===========================================
@ -804,7 +944,7 @@
.grid { .grid {
display: grid; display: grid;
gap: 20px; gap: 14px;
} }
.grid-cols-2 { .grid-cols-2 {
@ -817,32 +957,32 @@
.stat-grid { .stat-grid {
display: grid; display: grid;
gap: 14px; gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
} }
.note-grid { .note-grid {
display: grid; display: grid;
gap: 16px; gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
} }
.row { .row {
display: flex; display: flex;
gap: 12px; gap: 10px;
align-items: center; align-items: center;
} }
.stack { .stack {
display: grid; display: grid;
gap: 12px; gap: 10px;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.filters { .filters {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 12px;
align-items: center; align-items: center;
} }
@ -852,58 +992,14 @@
@media (max-width: 1100px) { @media (max-width: 1100px) {
.shell { .shell {
--shell-pad: 12px; --shell-pad: 10px;
--shell-gap: 12px; --shell-gap: 10px;
grid-template-columns: 1fr; --shell-nav-width: 200px;
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 { .topbar {
position: static; padding: 10px 12px;
padding: 12px 14px; gap: 8px;
gap: 10px;
} }
.topbar-search__kbd { .topbar-search__kbd {
@ -914,6 +1010,30 @@
flex-wrap: nowrap; 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-head,
.table-row { .table-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -2,60 +2,10 @@
Mobile Layout Mobile Layout
=========================================== */ =========================================== */
/* Tablet: Horizontal nav */ /* Tablet: keep side nav vertical, narrow sidebar */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.sidebar { .shell {
flex-direction: row; --shell-nav-width: 200px;
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;
} }
} }
@ -64,6 +14,7 @@
.shell { .shell {
--shell-pad: 8px; --shell-pad: 8px;
--shell-gap: 8px; --shell-gap: 8px;
--shell-nav-width: 180px;
} }
/* Topbar */ /* Topbar */
@ -142,8 +93,12 @@
/* Content — compact header on chat, hide on other tabs */ /* Content — compact header on chat, hide on other tabs */
.content-header { .content-header {
padding: 0; height: 64px;
max-height: 48px; 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 { .content:not(.content--chat) .content-header {
@ -151,7 +106,7 @@
} }
.content--chat .page-title { .content--chat .page-title {
font-size: 18px; font-size: 16px;
} }
.content--chat .page-sub { .content--chat .page-sub {
@ -159,8 +114,8 @@
} }
.content { .content {
padding: 4px 4px 16px; padding: 4px 6px 12px;
gap: 12px; gap: 10px;
} }
/* Cards */ /* Cards */
@ -226,22 +181,7 @@
/* Chat */ /* Chat */
.chat-agent-bar { .chat-agent-bar {
padding: 4px 8px; padding: 2px 6px;
gap: 4px;
}
.chat-agent-bar__name {
font-size: 11px;
}
.chat-agent-select {
font-size: 11px;
padding: 2px 16px 2px 4px;
}
.chat-sessions-summary {
padding: 2px 4px;
font-size: 10px;
} }
.chat-header { .chat-header {
@ -366,18 +306,10 @@
font-size: 11px; font-size: 11px;
} }
.theme-toggle { .theme-select {
height: 28px; height: 26px;
} min-width: 78px;
font-size: 11px;
.theme-btn svg {
width: 12px;
height: 12px;
}
.theme-icon {
width: 12px;
height: 12px;
} }
} }
@ -440,12 +372,9 @@
padding: 3px 6px; padding: 3px 6px;
} }
.theme-toggle { .theme-select {
height: 26px; height: 24px;
} min-width: 72px;
font-size: 10px;
.theme-icon {
width: 11px;
height: 11px;
} }
} }

View File

@ -170,11 +170,6 @@ export function connectGateway(host: GatewayHost) {
return; return;
} }
host.connected = false; host.connected = false;
// Code 1008 = Policy Violation (auth failure) — show the gateway's reason directly
if (code === 1008) {
host.lastError = reason || "Authentication failed. Check your gateway token.";
return;
}
// Code 1012 = Service Restart (expected during config saves, don't show as error) // Code 1012 = Service Restart (expected during config saves, don't show as error)
if (code !== 1012) { if (code !== 1012) {
host.lastError = `disconnected (${code}): ${reason || "no reason"}`; host.lastError = `disconnected (${code}): ${reason || "no reason"}`;

View File

@ -84,18 +84,59 @@ export function renderTab(state: AppViewState, tab: Tab) {
`; `;
} }
export function renderChatControls(state: AppViewState) { export function renderChatSessionSelect(state: AppViewState) {
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
const sessionOptions = resolveSessionOptions( const sessionOptions = resolveSessionOptions(
state.sessionKey, state.sessionKey,
state.sessionsResult, state.sessionsResult,
mainSessionKey, 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 disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding; const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode; const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html` const refreshIcon = html`
<svg <svg
width="18" width="18"
@ -131,43 +172,6 @@ export function renderChatControls(state: AppViewState) {
`; `;
return html` return html`
<div class="chat-controls"> <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 <button
class="btn btn--sm btn--icon" class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected} ?disabled=${state.chatLoading || !state.connected}
@ -396,55 +400,30 @@ function resolveSessionOptions(
return options; return options;
} }
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons }; type ThemeOption = { id: ThemeMode; label: string };
const THEME_OPTIONS: ThemeOption[] = [ const THEME_OPTIONS: ThemeOption[] = [
{ id: "dark", label: "Dark", iconKey: "monitor" }, { id: "dark", label: "Claw" },
{ id: "light", label: "Light", iconKey: "book" }, { id: "light", label: "Light" },
{ id: "openknot", label: "Knot", iconKey: "zap" }, { id: "openknot", label: "Knot" },
{ id: "fieldmanual", label: "Field", iconKey: "terminal" }, { id: "fieldmanual", label: "Field" },
{ id: "clawdash", label: "Chrome", iconKey: "settings" }, { id: "clawdash", label: "Chrome" },
]; ];
export function renderThemeToggle(state: AppViewState) { 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` return html`
<div <select
class="theme-toggle" class="theme-select"
@mouseleave=${handleCollapse} .value=${state.theme}
@focusout=${(e: FocusEvent) => { aria-label="Theme"
const toggle = e.currentTarget as HTMLElement; title="Theme"
requestAnimationFrame(() => { @change=${(e: Event) => {
if (!toggle.contains(document.activeElement)) { const select = e.target as HTMLSelectElement;
handleCollapse(); const next = select.value as ThemeMode;
} const context: ThemeTransitionContext = { element: select };
}); state.setTheme(next, context);
}} }}
> >
${state.themeOrder.map((id) => { ${THEME_OPTIONS.map((opt) => html`<option value=${opt.id}>${opt.label}</option>`)}
const opt = THEME_OPTIONS.find((o) => o.id === id)!; </select>
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>
`; `;
} }

View File

@ -6,7 +6,12 @@ import {
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { refreshChatAvatar } from "./app-chat.ts"; import { refreshChatAvatar } from "./app-chat.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts";
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers.ts"; import {
renderChatControls,
renderChatSessionSelect,
renderTab,
renderThemeToggle,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts"; import type { AppViewState } from "./app-view-state.ts";
import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts"; import { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
@ -59,7 +64,6 @@ import "./components/dashboard-header.ts";
import { icons } from "./icons.ts"; import { icons } from "./icons.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts"; import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import { renderAgents } from "./views/agents.ts"; import { renderAgents } from "./views/agents.ts";
import { renderBottomTabs } from "./views/bottom-tabs.ts";
import { renderChannels } from "./views/channels.ts"; import { renderChannels } from "./views/channels.ts";
import { renderChat } from "./views/chat.ts"; import { renderChat } from "./views/chat.ts";
import { renderCommandPalette } from "./views/command-palette.ts"; import { renderCommandPalette } from "./views/command-palette.ts";
@ -79,6 +83,33 @@ import { renderSkills } from "./views/skills.ts";
const AVATAR_DATA_RE = /^data:/i; const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//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 { function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? []; const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey); const parsed = parseAgentSessionKey(state.sessionKey);
@ -140,11 +171,15 @@ export function renderApp(state: AppViewState) {
onNavigate: (tab) => { onNavigate: (tab) => {
state.setTab(tab as import("./navigation.ts").Tab); state.setTab(tab as import("./navigation.ts").Tab);
}, },
onSlashCommand: (_cmd) => { onSlashCommand: (cmd) => {
state.setTab("chat" as import("./navigation.ts").Tab); 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" : ""}"> <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"
>
<header class="topbar"> <header class="topbar">
<dashboard-header .tab=${state.tab}></dashboard-header> <dashboard-header .tab=${state.tab}></dashboard-header>
<button <button
@ -159,30 +194,6 @@ export function renderApp(state: AppViewState) {
<kbd class="topbar-search__kbd">K</kbd> <kbd class="topbar-search__kbd">K</kbd>
</button> </button>
<div class="topbar-status"> <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}
${
state.streamMode
? html`
<span class="topbar-redact__label">Stream Mode</span>
`
: nothing
}
</button>
<span class="topbar-divider"></span>
<div class="topbar-connection ${state.connected ? "topbar-connection--ok" : ""}"> <div class="topbar-connection ${state.connected ? "topbar-connection--ok" : ""}">
<span class="topbar-connection__dot"></span> <span class="topbar-connection__dot"></span>
<span class="topbar-connection__label">${state.connected ? t("common.ok") : t("common.offline")}</span> <span class="topbar-connection__label">${state.connected ? t("common.ok") : t("common.offline")}</span>
@ -191,6 +202,7 @@ export function renderApp(state: AppViewState) {
${renderThemeToggle(state)} ${renderThemeToggle(state)}
</div> </div>
</header> </header>
<div class="shell-nav">
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}"> <aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
<div class="sidebar-header"> <div class="sidebar-header">
${ ${
@ -256,42 +268,61 @@ export function renderApp(state: AppViewState) {
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<a <div class="sidebar-footer__docs-block">
class="nav-item nav-item--external" <a
href="https://docs.openclaw.ai" class="nav-item nav-item--external"
target="_blank" href="https://docs.openclaw.ai"
rel="noreferrer" target="_blank"
title="${t("common.docs")} (opens in new tab)" rel="noreferrer"
> title="${t("common.docs")} (opens in new tab)"
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span> >
${ <span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
!state.settings.navCollapsed ${
? html` !state.settings.navCollapsed
? html`
<span class="nav-item__text">${t("common.docs")}</span> <span class="nav-item__text">${t("common.docs")}</span>
<span class="nav-item__external-icon">${icons.externalLink}</span> <span class="nav-item__external-icon">${icons.externalLink}</span>
` `
: nothing : nothing
} }
</a> </a>
${(() => { ${(() => {
const snapshot = state.hello?.snapshot as { server?: { version?: string } } | undefined; const snapshot = state.hello?.snapshot as
const version = snapshot?.server?.version ?? ""; | { server?: { version?: string } }
return version | undefined;
? html` const version = snapshot?.server?.version ?? "";
<div class="sidebar-version" title=${`v${version}`}> return version
${ ? html`
!state.settings.navCollapsed <div class="sidebar-version" title=${`v${version}`}>
? html`<span class="sidebar-version__text">v${version}</span>` ${
: html` !state.settings.navCollapsed
<span class="sidebar-version__dot"></span> ? html`<span class="sidebar-version__text">v${version}</span>`
` : html`
} <span class="sidebar-version__dot"></span>
</div> `
` }
: nothing; </div>
})()} `
: nothing;
})()}
</div>
</div> </div>
</aside> </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" : ""}"> <main class="content ${isChat ? "content--chat" : ""}">
${ ${
state.updateAvailable state.updateAvailable
@ -308,8 +339,14 @@ export function renderApp(state: AppViewState) {
} }
<section class="content-header"> <section class="content-header">
<div> <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>`} 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>`}
</div> </div>
<div class="page-meta"> <div class="page-meta">
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing} ${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
@ -431,12 +468,37 @@ export function renderApp(state: AppViewState) {
includeGlobal: state.sessionsIncludeGlobal, includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown, includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath, basePath: state.basePath,
searchQuery: state.sessionsSearchQuery,
sortColumn: state.sessionsSortColumn,
sortDir: state.sessionsSortDir,
page: state.sessionsPage,
pageSize: state.sessionsPageSize,
actionsOpenKey: state.sessionsActionsOpenKey,
onFiltersChange: (next) => { onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit; state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal; state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown; 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), onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch), onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSessionAndRefresh(state, key), onDelete: (key) => deleteSessionAndRefresh(state, key),
@ -478,7 +540,7 @@ export function renderApp(state: AppViewState) {
${ ${
state.tab === "agents" state.tab === "agents"
? renderAgents({ ? renderAgents({
basePath: state.basePath, basePath: state.basePath ?? "",
loading: state.agentsLoading, loading: state.agentsLoading,
error: state.agentsError, error: state.agentsError,
agentsList: state.agentsList, agentsList: state.agentsList,
@ -521,10 +583,6 @@ export function renderApp(state: AppViewState) {
agentId: state.agentSkillsAgentId, agentId: state.agentSkillsAgentId,
filter: state.skillsFilter, filter: state.skillsFilter,
}, },
sidebarFilter: state.agentsSidebarFilter,
onSidebarFilterChange: (value) => {
state.agentsSidebarFilter = value;
},
onRefresh: async () => { onRefresh: async () => {
await loadAgents(state); await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? []; const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
@ -1060,6 +1118,7 @@ export function renderApp(state: AppViewState) {
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio), onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName, assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar, assistantAvatar: state.assistantAvatar,
basePath: state.basePath ?? "",
}) })
: nothing : nothing
} }
@ -1151,10 +1210,7 @@ export function renderApp(state: AppViewState) {
</main> </main>
${renderExecApprovalPrompt(state)} ${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)} ${renderGatewayUrlConfirmation(state)}
${renderBottomTabs({ ${nothing}
activeTab: state.tab,
onTabChange: (tab) => state.setTab(tab),
})}
</div> </div>
`; `;
} }

View File

@ -19,6 +19,7 @@ const createHost = (tab: Tab): SettingsHost => ({
splitRatio: 0.6, splitRatio: 0.6,
navCollapsed: false, navCollapsed: false,
navGroupsCollapsed: {}, navGroupsCollapsed: {},
navWidth: 220,
}, },
theme: "dark", theme: "dark",
themeResolved: "dark", themeResolved: "dark",

View File

@ -269,7 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
} }
const root = document.documentElement; const root = document.documentElement;
root.dataset.theme = resolved; root.dataset.theme = resolved;
root.style.colorScheme = "dark"; root.style.colorScheme = resolved === "light" ? "light" : "dark";
} }
export function syncTabWithLocation(host: SettingsHost, replace: boolean) { export function syncTabWithLocation(host: SettingsHost, replace: boolean) {

View File

@ -146,7 +146,6 @@ export type AppViewState = {
agentSkillsError: string | null; agentSkillsError: string | null;
agentSkillsReport: SkillStatusReport | null; agentSkillsReport: SkillStatusReport | null;
agentSkillsAgentId: string | null; agentSkillsAgentId: string | null;
agentsSidebarFilter: string;
sessionsLoading: boolean; sessionsLoading: boolean;
sessionsResult: SessionsListResult | null; sessionsResult: SessionsListResult | null;
sessionsError: string | null; sessionsError: string | null;
@ -154,6 +153,12 @@ export type AppViewState = {
sessionsFilterLimit: string; sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean; sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean; sessionsIncludeUnknown: boolean;
sessionsSearchQuery: string;
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
sessionsSortDir: "asc" | "desc";
sessionsPage: number;
sessionsPageSize: number;
sessionsActionsOpenKey: string | null;
usageLoading: boolean; usageLoading: boolean;
usageResult: SessionsUsageResult | null; usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null; usageCostSummary: CostUsageSummary | null;

View File

@ -231,7 +231,6 @@ export class OpenClawApp extends LitElement {
@state() agentSkillsError: string | null = null; @state() agentSkillsError: string | null = null;
@state() agentSkillsReport: SkillStatusReport | null = null; @state() agentSkillsReport: SkillStatusReport | null = null;
@state() agentSkillsAgentId: string | null = null; @state() agentSkillsAgentId: string | null = null;
@state() agentsSidebarFilter = "";
@state() sessionsLoading = false; @state() sessionsLoading = false;
@state() sessionsResult: SessionsListResult | null = null; @state() sessionsResult: SessionsListResult | null = null;
@ -240,6 +239,12 @@ export class OpenClawApp extends LitElement {
@state() sessionsFilterLimit = "120"; @state() sessionsFilterLimit = "120";
@state() sessionsIncludeGlobal = true; @state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false; @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() usageLoading = false;
@state() usageResult: import("./types.js").SessionsUsageResult | null = null; @state() usageResult: import("./types.js").SessionsUsageResult | null = null;
@ -464,12 +469,6 @@ export class OpenClawApp extends LitElement {
return [active, ...rest]; return [active, ...rest];
} }
handleThemeToggleCollapse() {
setTimeout(() => {
this.themeOrder = this.buildThemeOrder(this.theme);
}, 80);
}
async loadOverview() { async loadOverview() {
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]); await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
} }

View File

@ -5,6 +5,7 @@ import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { detectTextDirection } from "../text-direction.ts"; import { detectTextDirection } from "../text-direction.ts";
import type { MessageGroup, ToolCard } from "../types/chat-types.ts"; import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import { agentLogoUrl } from "../views/agents-utils.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import { import {
extractTextCached, extractTextCached,
@ -56,10 +57,10 @@ function extractImages(message: unknown): ImageBlock[] {
return images; return images;
} }
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
${renderAvatar("assistant", assistant)} ${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages"> <div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true"> <div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots"> <span class="chat-reading-indicator__dots">
@ -76,6 +77,7 @@ export function renderStreamingGroup(
startedAt: number, startedAt: number,
onOpenSidebar?: (content: string) => void, onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity, assistant?: AssistantIdentity,
basePath?: string,
) { ) {
const timestamp = new Date(startedAt).toLocaleTimeString([], { const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric", hour: "numeric",
@ -85,7 +87,7 @@ export function renderStreamingGroup(
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
${renderAvatar("assistant", assistant)} ${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${renderGroupedMessage( ${renderGroupedMessage(
{ {
@ -112,6 +114,7 @@ export function renderMessageGroup(
showReasoning: boolean; showReasoning: boolean;
assistantName?: string; assistantName?: string;
assistantAvatar?: string | null; assistantAvatar?: string | null;
basePath?: string;
onDelete?: () => void; onDelete?: () => void;
}, },
) { ) {
@ -132,10 +135,14 @@ export function renderMessageGroup(
return html` return html`
<div class="chat-group ${roleClass}"> <div class="chat-group ${roleClass}">
${renderAvatar(group.role, { ${renderAvatar(
name: assistantName, group.role,
avatar: opts.assistantAvatar ?? null, {
})} name: assistantName,
avatar: opts.assistantAvatar ?? null,
},
opts.basePath,
)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${group.messages.map((item, index) => ${group.messages.map((item, index) =>
renderGroupedMessage( renderGroupedMessage(
@ -166,7 +173,11 @@ export function renderMessageGroup(
`; `;
} }
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) { function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
basePath?: string,
) {
const normalized = normalizeRoleForGrouping(role); const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant"; const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || ""; const assistantAvatar = assistant?.avatar?.trim() || "";
@ -195,9 +206,28 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
alt="${assistantName}" 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>`; 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>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
} }

View File

@ -20,7 +20,7 @@ export class DashboardHeader extends LitElement {
class="dashboard-header__breadcrumb-link" class="dashboard-header__breadcrumb-link"
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))} @click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
> >
ClawDash OpenClaw
</span> </span>
<span class="dashboard-header__breadcrumb-sep"></span> <span class="dashboard-header__breadcrumb-sep"></span>
<span class="dashboard-header__breadcrumb-current">${label}</span> <span class="dashboard-header__breadcrumb-current">${label}</span>

View File

@ -109,13 +109,6 @@ export class GatewayBrowserClient {
this.ws = null; this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`)); this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason }); this.opts.onClose?.({ code: ev.code, reason });
// 1008 = Policy Violation (gateway auth rejection).
// Don't auto-reconnect on auth failures — surface the login gate
// so the user can fix their token/password instead of looping.
if (ev.code === 1008) {
this.closed = true;
return;
}
this.scheduleReconnect(); this.scheduleReconnect();
}); });
this.ws.addEventListener("error", () => { this.ws.addEventListener("error", () => {

View File

@ -334,6 +334,31 @@ export const icons = {
/> />
</svg> </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` refresh: html`
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
@ -369,6 +394,21 @@ export const icons = {
<path d="m2 2 20 20" /> <path d="m2 2 20 20" />
</svg> </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; } as const;
export type IconName = keyof typeof icons; export type IconName = keyof typeof icons;

View File

@ -13,6 +13,7 @@ export type UiSettings = {
chatShowThinking: boolean; chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (180400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string; locale?: string;
}; };
@ -33,6 +34,7 @@ export function loadSettings(): UiSettings {
chatShowThinking: true, chatShowThinking: true,
splitRatio: 0.6, splitRatio: 0.6,
navCollapsed: false, navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {}, navGroupsCollapsed: {},
}; };
@ -74,6 +76,10 @@ export function loadSettings(): UiSettings {
: defaults.splitRatio, : defaults.splitRatio,
navCollapsed: navCollapsed:
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.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: navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed ? parsed.navGroupsCollapsed

View File

@ -1,5 +1,5 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { AgentsFilesListResult, AgentsListResult } from "../types.ts"; import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import { import {
buildModelOptions, buildModelOptions,
normalizeModelValue, normalizeModelValue,
@ -13,9 +13,13 @@ import type { AgentsPanel } from "./agents.ts";
export function renderAgentOverview(params: { export function renderAgentOverview(params: {
agent: AgentsListResult["agents"][number]; agent: AgentsListResult["agents"][number];
basePath: string;
defaultId: string | null; defaultId: string | null;
configForm: Record<string, unknown> | null; configForm: Record<string, unknown> | null;
agentFilesList: AgentsFilesListResult | null; agentFilesList: AgentsFilesListResult | null;
agentIdentity: AgentIdentityResult | null;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
configLoading: boolean; configLoading: boolean;
configSaving: boolean; configSaving: boolean;
configDirty: boolean; configDirty: boolean;
@ -77,11 +81,12 @@ export function renderAgentOverview(params: {
} }
}; };
const fallbackSummary = fallbackChips.length > 0 ? `${fallbackChips.length} configured` : "none";
return html` return html`
<section class="card"> <section class="card">
<div class="agents-overview-grid"> <div class="card-title">Overview</div>
<div class="card-sub">Workspace paths and identity metadata.</div>
<div class="agents-overview-grid" style="margin-top: 16px;">
<div class="agent-kv"> <div class="agent-kv">
<div class="label">Workspace</div> <div class="label">Workspace</div>
<div> <div>
@ -101,25 +106,23 @@ export function renderAgentOverview(params: {
<div class="label">Skills Filter</div> <div class="label">Skills Filter</div>
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div> <div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
</div> </div>
<div class="agent-kv">
<div class="label">Fallbacks</div>
<div class="mono">${fallbackSummary}</div>
</div>
</div> </div>
${ ${
configDirty configDirty
? html` ? html`
<div class="callout warn" style="margin-top: 12px">You have unsaved config changes.</div> <div class="callout warn" style="margin-top: 16px">You have unsaved config changes.</div>
` `
: nothing : nothing
} }
<div class="agent-model-select"> <div class="agent-model-select" style="margin-top: 20px;">
<div class="row" style="gap: 12px; flex-wrap: wrap; align-items: flex-end;"> <div class="label">Model Selection</div>
<label class="field" style="min-width: 240px; flex: 1;"> <div class="agent-model-fields">
<label class="field">
<span>Primary model${isDefault ? " (default)" : ""}</span> <span>Primary model${isDefault ? " (default)" : ""}</span>
<select <select
.value=${effectivePrimary ?? ""}
?disabled=${disabled} ?disabled=${disabled}
@change=${(e: Event) => @change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)} onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
@ -136,7 +139,7 @@ export function renderAgentOverview(params: {
${buildModelOptions(configForm, effectivePrimary ?? undefined)} ${buildModelOptions(configForm, effectivePrimary ?? undefined)}
</select> </select>
</label> </label>
<div class="field" style="min-width: 240px; flex: 1;"> <div class="field">
<span>Fallbacks</span> <span>Fallbacks</span>
<div class="agent-chip-input" @click=${(e: Event) => { <div class="agent-chip-input" @click=${(e: Event) => {
const container = e.currentTarget as HTMLElement; const container = e.currentTarget as HTMLElement;
@ -173,18 +176,19 @@ export function renderAgentOverview(params: {
/> />
</div> </div>
</div> </div>
<div class="agent-model-actions"> </div>
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}> <div class="agent-model-actions">
Reload Config <button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
</button> Reload Config
<button </button>
class="btn btn--sm primary" <button
?disabled=${configSaving || !configDirty} type="button"
@click=${onConfigSave} class="btn btn--sm primary"
> ?disabled=${configSaving || !configDirty}
${configSaving ? "Saving…" : "Save"} @click=${onConfigSave}
</button> >
</div> ${configSaving ? "Saving…" : "Save"}
</button>
</div> </div>
</div> </div>
</section> </section>

View File

@ -411,10 +411,7 @@ export function buildModelOptions(
<option value="" disabled>No configured models</option> <option value="" disabled>No configured models</option>
`; `;
} }
return options.map( return options.map((option) => html`<option value=${option.value}>${option.label}</option>`);
(option) =>
html`<option value=${option.value} ?selected=${current === option.value}>${option.label}</option>`,
);
} }
type CompiledPattern = type CompiledPattern =

View File

@ -15,14 +15,7 @@ import {
renderAgentCron, renderAgentCron,
} from "./agents-panels-status-files.ts"; } from "./agents-panels-status-files.ts";
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
import { import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
agentAvatarHue,
agentBadgeText,
agentLogoUrl,
buildAgentContext,
normalizeAgentLabel,
resolveAgentAvatarUrl,
} from "./agents-utils.ts";
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
@ -80,8 +73,6 @@ export type AgentsProps = {
agentIdentityError: string | null; agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>; agentIdentityById: Record<string, AgentIdentityResult>;
agentSkills: AgentSkillsState; agentSkills: AgentSkillsState;
sidebarFilter: string;
onSidebarFilterChange: (value: string) => void;
onRefresh: () => void; onRefresh: () => void;
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void; onSelectPanel: (panel: AgentsPanel) => void;
@ -115,14 +106,6 @@ export function renderAgents(props: AgentsProps) {
? (agents.find((agent) => agent.id === selectedId) ?? null) ? (agents.find((agent) => agent.id === selectedId) ?? null)
: null; : null;
const sidebarFilter = props.sidebarFilter.trim().toLowerCase();
const filteredAgents = sidebarFilter
? agents.filter((agent) => {
const label = normalizeAgentLabel(agent).toLowerCase();
return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter);
})
: agents;
const channelEntryCount = props.channels.snapshot const channelEntryCount = props.channels.snapshot
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length ? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
: null; : null;
@ -138,73 +121,81 @@ export function renderAgents(props: AgentsProps) {
return html` return html`
<div class="agents-layout"> <div class="agents-layout">
<section class="card agents-sidebar"> <section class="agents-toolbar">
<div class="row" style="justify-content: space-between;"> <div class="agents-toolbar-row">
<div> <span class="agents-toolbar-label">Agent</span>
<div class="card-title">Agents</div> <div class="agents-control-row">
<div class="card-sub">${agents.length} configured.</div> <div class="agents-control-select">
</div> <select
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}> class="agents-select"
${props.loading ? "Loading…" : "Refresh"} .value=${selectedId ?? ""}
</button> ?disabled=${props.loading || agents.length === 0}
</div> @change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
${ >
agents.length > 1 ${
? html` agents.length === 0
<input ? html`
class="field" <option value="">No agents</option>
type="text" `
placeholder="Filter agents…" : agents.map(
.value=${props.sidebarFilter} (agent) => html`
@input=${(e: Event) => <option value=${agent.id} ?selected=${agent.id === selectedId}>
props.onSidebarFilterChange((e.target as HTMLInputElement).value)} ${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
style="margin-top: 8px;" </option>
/> `,
` )
: nothing }
} </select>
${ </div>
props.error <div class="agents-control-actions">
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ${
: nothing selectedAgent
} ? html`
<div class="agent-list" style="margin-top: 12px;"> <div class="agent-actions-wrap">
${ <button
filteredAgents.length === 0 class="agent-actions-toggle"
? html` type="button"
<div class="muted">${sidebarFilter ? "No matching agents." : "No agents found."}</div> @click=${() => {
` actionsMenuOpen = !actionsMenuOpen;
: filteredAgents.map((agent) => { }}
const badge = agentBadgeText(agent.id, defaultId); ></button>
const avatarUrl = resolveAgentAvatarUrl(
agent,
props.agentIdentityById[agent.id] ?? null,
);
const hue = agentAvatarHue(agent.id);
const logoUrl = agentLogoUrl(props.basePath);
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}">
${ ${
avatarUrl actionsMenuOpen
? html`<img src=${avatarUrl} alt="" class="agent-avatar__img" />` ? html`
: html`<img src=${logoUrl} alt="" class="agent-avatar__img agent-avatar__logo" />` <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> </div>
<div class="agent-info"> `
<div class="agent-title">${normalizeAgentLabel(agent)}</div> : nothing
<div class="agent-sub mono">${agent.id}</div> }
</div> <button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing} ${props.loading ? "Loading…" : "Refresh"}
</button> </button>
`; </div>
}) </div>
}
</div> </div>
${
props.error
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
: nothing
}
</section> </section>
<section class="agents-main"> <section class="agents-main">
${ ${
@ -216,21 +207,18 @@ export function renderAgents(props: AgentsProps) {
</div> </div>
` `
: html` : html`
${renderAgentHeader(
selectedAgent,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
props.onSetDefault,
props.basePath,
)}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)} ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
${ ${
props.activePanel === "overview" props.activePanel === "overview"
? renderAgentOverview({ ? renderAgentOverview({
agent: selectedAgent, agent: selectedAgent,
basePath: props.basePath,
defaultId, defaultId,
configForm: props.config.form, configForm: props.config.form,
agentFilesList: props.agentFiles.list, agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading, configLoading: props.config.loading,
configSaving: props.config.saving, configSaving: props.config.saving,
configDirty: props.config.dirty, configDirty: props.config.dirty,
@ -347,79 +335,6 @@ export function renderAgents(props: AgentsProps) {
let actionsMenuOpen = false; let actionsMenuOpen = false;
function renderAgentHeader(
agent: AgentsListResult["agents"][number],
defaultId: string | null,
agentIdentity: AgentIdentityResult | null,
onSetDefault: (agentId: string) => void,
basePath: string,
) {
const badge = agentBadgeText(agent.id, defaultId);
const displayName = normalizeAgentLabel(agent);
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
const avatarUrl = resolveAgentAvatarUrl(agent, agentIdentity);
const hue = agentAvatarHue(agent.id);
const isDefault = Boolean(defaultId && agent.id === defaultId);
const copyId = () => {
void navigator.clipboard.writeText(agent.id);
actionsMenuOpen = false;
};
const logoUrl = agentLogoUrl(basePath);
return html`
<section class="card agent-header">
<div class="agent-header-main">
<div class="agent-avatar agent-avatar--lg" style="--agent-hue: ${hue}">
${
avatarUrl
? html`<img src=${avatarUrl} alt="" class="agent-avatar__img" />`
: html`<img src=${logoUrl} alt="" class="agent-avatar__img agent-avatar__logo" />`
}
</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( function renderAgentTabs(
active: AgentsPanel, active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void, onSelect: (panel: AgentsPanel) => void,

View File

@ -21,6 +21,7 @@ import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts"; import type { SessionsListResult } from "../types.ts";
import type { ChatItem, MessageGroup } from "../types/chat-types.ts"; import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts"; import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts"; import "../components/resizable-divider.ts";
@ -93,6 +94,7 @@ export type ChatProps = {
onCloseSidebar?: () => void; onCloseSidebar?: () => void;
onSplitRatioChange?: (ratio: number) => void; onSplitRatioChange?: (ratio: number) => void;
onChatScroll?: (event: Event) => void; onChatScroll?: (event: Event) => void;
basePath?: string;
}; };
const COMPACTION_TOAST_DURATION_MS = 5000; const COMPACTION_TOAST_DURATION_MS = 5000;
@ -137,9 +139,6 @@ let slashMenuIndex = 0;
let searchOpen = false; let searchOpen = false;
let searchQuery = ""; let searchQuery = "";
let pinnedExpanded = false; let pinnedExpanded = false;
let voiceActive = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let recognition: any = null;
function adjustTextareaHeight(el: HTMLTextAreaElement) { function adjustTextareaHeight(el: HTMLTextAreaElement) {
el.style.height = "auto"; el.style.height = "auto";
@ -361,52 +360,6 @@ function tokenEstimate(draft: string): string | null {
return `~${Math.ceil(draft.length / 4)} tokens`; 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 { function exportMarkdown(props: ChatProps): void {
const history = Array.isArray(props.messages) ? props.messages : []; const history = Array.isArray(props.messages) ? props.messages : [];
if (history.length === 0) { if (history.length === 0) {
@ -432,7 +385,7 @@ function exportMarkdown(props: ChatProps): void {
function renderWelcomeState(props: ChatProps): TemplateResult { function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant"; const name = props.assistantName || "Assistant";
const avatar = props.assistantAvatar ?? props.assistantAvatarUrl; const avatar = props.assistantAvatar ?? props.assistantAvatarUrl;
const initials = name.slice(0, 2).toUpperCase(); const logoUrl = agentLogoUrl(props.basePath ?? "");
return html` return html`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)"> <div class="agent-chat__welcome" style="--agent-color: var(--accent)">
@ -440,11 +393,11 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
${ ${
avatar avatar
? html`<img src=${avatar} alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />` ? html`<img src=${avatar} alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />`
: html`<div class="agent-chat__avatar">${initials}</div>` : html`<div class="agent-chat__avatar agent-chat__avatar--logo"><img src=${logoUrl} alt="OpenClaw" /></div>`
} }
<h2>${name}</h2> <h2>${name}</h2>
<div class="agent-chat__badges"> <div class="agent-chat__badges">
<span class="agent-chat__badge">${icons.spark} Ready to chat</span> <span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
</div> </div>
<p class="agent-chat__hint"> <p class="agent-chat__hint">
Type a message below &middot; <kbd>/</kbd> for commands Type a message below &middot; <kbd>/</kbd> for commands
@ -604,10 +557,6 @@ export function renderChat(props: ChatProps) {
const hasAttachments = (props.attachments?.length ?? 0) > 0; const hasAttachments = (props.attachments?.length ?? 0) > 0;
const tokens = tokenEstimate(props.draft); 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 const placeholder = props.connected
? hasAttachments ? hasAttachments
? "Add a message or paste more images..." ? "Add a message or paste more images..."
@ -663,7 +612,7 @@ export function renderChat(props: ChatProps) {
`; `;
} }
if (item.kind === "reading-indicator") { if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity); return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
} }
if (item.kind === "stream") { if (item.kind === "stream") {
return renderStreamingGroup( return renderStreamingGroup(
@ -671,6 +620,7 @@ export function renderChat(props: ChatProps) {
item.startedAt, item.startedAt,
props.onOpenSidebar, props.onOpenSidebar,
assistantIdentity, assistantIdentity,
props.basePath,
); );
} }
if (item.kind === "group") { if (item.kind === "group") {
@ -682,6 +632,7 @@ export function renderChat(props: ChatProps) {
showReasoning, showReasoning,
assistantName: props.assistantName, assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar, assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
onDelete: () => { onDelete: () => {
deleted.delete(item.key); deleted.delete(item.key);
requestUpdate(); requestUpdate();
@ -808,8 +759,6 @@ export function renderChat(props: ChatProps) {
${renderSearchBar(requestUpdate)} ${renderSearchBar(requestUpdate)}
${renderPinnedSection(props, pinned, requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
${renderAgentBar(props)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"> <div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div <div
class="chat-main" class="chat-main"
@ -930,39 +879,13 @@ export function renderChat(props: ChatProps) {
${icons.paperclip} ${icons.paperclip}
</button> </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} ${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
</div> </div>
<div class="agent-chat__toolbar-right"> <div class="agent-chat__toolbar-right">
<button class="btn-ghost" @click=${() => { ${nothing /* search hidden for now */}
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}> <button class="btn-ghost" @click=${() => exportMarkdown(props)} title="Export" ?disabled=${props.messages.length === 0}>
${icons.download} ${icons.download}
</button> </button>
@ -997,83 +920,6 @@ 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; const CHAT_HISTORY_RENDER_LIMIT = 200;
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> { function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {

View File

@ -120,49 +120,26 @@ export function renderDebug(props: DebugProps) {
</section> </section>
<section class="card" style="margin-top: 18px;"> <section class="card" style="margin-top: 18px;">
<div class="row" style="justify-content: space-between; align-items: baseline;"> <div class="card-title">Event Log</div>
<div> <div class="card-sub">Latest gateway events.</div>
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
</div>
${
props.eventLog.length > 0
? html`<button
class="btn btn-sm"
@click=${(e: Event) => {
const section = (e.target as HTMLElement).closest("section")!;
const details = section.querySelectorAll<HTMLDetailsElement>(
"details.debug-event-entry",
);
const allOpen = Array.from(details).every((d) => d.open);
details.forEach((d) => (d.open = !allOpen));
}}
>${"Expand All / Collapse All"}</button>`
: nothing
}
</div>
${ ${
props.eventLog.length === 0 props.eventLog.length === 0
? html` ? html`
<div class="muted" style="margin-top: 12px">No events yet.</div> <div class="muted" style="margin-top: 12px">No events yet.</div>
` `
: html` : html`
<div class="debug-event-log-scroll"> <div class="list" style="margin-top: 12px;">
${props.eventLog.map( ${props.eventLog.map(
(evt) => html` (evt) => html`
<details class="debug-event-entry"> <div class="list-item">
<summary class="debug-event-summary"> <div class="list-main">
<span class="debug-event-name">${evt.event}</span> <div class="list-title">${evt.event}</div>
<span class="debug-event-ts muted">${new Date(evt.ts).toLocaleTimeString()}</span> <div class="list-sub">${new Date(evt.ts).toLocaleTimeString()}</div>
</summary> </div>
${ <div class="list-meta">
evt.payload <pre class="code-block">${formatEventPayload(evt.payload)}</pre>
? html`<pre class="code-block debug-event-payload">${formatEventPayload(evt.payload)}</pre>` </div>
: html` </div>
<div class="muted" style="padding: 8px 0 4px">No payload.</div>
`
}
</details>
`, `,
)} )}
</div> </div>

View File

@ -30,16 +30,15 @@ export function renderLoginGate(state: AppViewState) {
/> />
</label> </label>
<label class="field"> <label class="field">
<span>${t("overview.access.token")}</span> <span>${t("overview.access.password")}</span>
<input <input
type="password" type="password"
.value=${state.password} .value=${state.password}
@input=${(e: Event) => { @input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
state.password = v; state.password = v;
state.applySettings({ ...state.settings, token: v });
}} }}
placeholder="${t("login.tokenPlaceholder")}" placeholder="${t("login.passwordPlaceholder")}"
@keydown=${(e: KeyboardEvent) => { @keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
state.connect(); state.connect();

View File

@ -2,7 +2,6 @@ import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { t } from "../../i18n/index.ts"; import { t } from "../../i18n/index.ts";
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts"; import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { formatNextRun } from "../presenter.ts"; import { formatNextRun } from "../presenter.ts";
import type { import type {
SessionsUsageResult, SessionsUsageResult,
@ -35,6 +34,25 @@ function blurDigits(value: string): TemplateResult {
return html`${unsafeHTML(blurred)}`; 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) { export function renderOverviewCards(props: OverviewCardsProps) {
const totals = props.usageResult?.totals; const totals = props.usageResult?.totals;
const totalCost = formatCost(totals?.totalCost); const totalCost = formatCost(totals?.totalCost);
@ -52,75 +70,75 @@ export function renderOverviewCards(props: OverviewCardsProps) {
const cronJobCount = props.cronJobs.length; const cronJobCount = props.cronJobs.length;
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").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` return html`
<section class="ov-cards"> <section class="ov-cards">
<div class="card ov-stat-card clickable" data-kind="cost" @click=${() => props.onNavigate("usage")}> ${cards.map((c) => renderStatCard(c, props.onNavigate))}
<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> </section>
${ ${
props.sessionsResult && props.sessionsResult.sessions.length > 0 sessions.length > 0
? html` ? html`
<section class="card ov-recent-sessions"> <section class="ov-recent">
<div class="card-title">${t("overview.cards.recentSessions")}</div> <h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
<div class="ov-session-list"> <ul class="ov-recent__list">
${props.sessionsResult.sessions.slice(0, 5).map( ${sessions.map(
(s) => html` (s) => html`
<div class="ov-session-row ${props.redacted ? "redacted" : ""}"> <li class="ov-recent__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="ov-recent__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="ov-recent__model">${s.model ?? ""}</span>
<span class="muted">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span> <span class="ov-recent__time">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
</div> </li>
`, `,
)} )}
</div> </ul>
</section> </section>
` `
: nothing : nothing

View File

@ -323,6 +323,8 @@ export function renderOverview(props: OverviewProps) {
: nothing : nothing
} }
<div class="ov-section-divider"></div>
${renderOverviewCards({ ${renderOverviewCards({
usageResult: props.usageResult, usageResult: props.usageResult,
sessionsResult: props.sessionsResult, sessionsResult: props.sessionsResult,
@ -336,6 +338,8 @@ export function renderOverview(props: OverviewProps) {
${renderOverviewAttention({ items: props.attentionItems })} ${renderOverviewAttention({ items: props.attentionItems })}
<div class="ov-section-divider"></div>
<div class="ov-bottom-grid" style="margin-top: 18px;"> <div class="ov-bottom-grid" style="margin-top: 18px;">
${renderOverviewEventLog({ ${renderOverviewEventLog({
events: props.eventLog, events: props.eventLog,

View File

@ -23,7 +23,18 @@ function buildProps(result: SessionsListResult): SessionsProps {
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
basePath: "", basePath: "",
searchQuery: "",
sortColumn: "updated",
sortDir: "desc",
page: 0,
pageSize: 10,
actionsOpenKey: null,
onFiltersChange: () => undefined, onFiltersChange: () => undefined,
onSearchChange: () => undefined,
onSortChange: () => undefined,
onPageChange: () => undefined,
onPageSizeChange: () => undefined,
onActionsOpenChange: () => undefined,
onRefresh: () => undefined, onRefresh: () => undefined,
onPatch: () => undefined, onPatch: () => undefined,
onDelete: () => undefined, onDelete: () => undefined,

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { formatRelativeTimestamp } from "../format.ts"; import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { pathForTab } from "../navigation.ts"; import { pathForTab } from "../navigation.ts";
import { formatSessionTokens } from "../presenter.ts"; import { formatSessionTokens } from "../presenter.ts";
import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
@ -13,12 +14,23 @@ export type SessionsProps = {
includeGlobal: boolean; includeGlobal: boolean;
includeUnknown: boolean; includeUnknown: boolean;
basePath: string; basePath: string;
searchQuery: string;
sortColumn: "key" | "kind" | "updated" | "tokens";
sortDir: "asc" | "desc";
page: number;
pageSize: number;
actionsOpenKey: string | null;
onFiltersChange: (next: { onFiltersChange: (next: {
activeMinutes: string; activeMinutes: string;
limit: string; limit: string;
includeGlobal: boolean; includeGlobal: boolean;
includeUnknown: boolean; includeUnknown: boolean;
}) => void; }) => 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; onRefresh: () => void;
onPatch: ( onPatch: (
key: string, key: string,
@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [
{ value: "full", label: "full" }, { value: "full", label: "full" },
] as const; ] as const;
const REASONING_LEVELS = ["", "off", "on", "stream"] 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 { function normalizeProviderId(provider?: string | null): string {
if (!provider) { if (!provider) {
@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
return value; 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) { export function renderSessions(props: SessionsProps) {
const rows = props.result?.sessions ?? []; const rawRows = props.result?.sessions ?? [];
const filtered = filterRows(rawRows, props.searchQuery);
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
const totalRows = sorted.length;
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
const page = Math.min(props.page, totalPages - 1);
const paginated = paginateRows(sorted, page, props.pageSize);
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
const isActive = props.sortColumn === col;
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
return html`
<th
data-sortable
data-sort-dir=${isActive ? props.sortDir : ""}
@click=${() => props.onSortChange(col, isActive ? nextDir : "desc")}
>
${label}
<span class="data-table-sort-icon">${icons.arrowUpDown}</span>
</th>
`;
};
return html` return html`
<section class="card"> ${
<div class="row" style="justify-content: space-between;"> props.actionsOpenKey
? html`
<div
class="data-table-overlay"
@click=${() => props.onActionsOpenChange(null)}
aria-hidden="true"
></div>
`
: nothing
}
<section class="card" style=${props.actionsOpenKey ? "position: relative; z-index: 41;" : ""}>
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<div> <div>
<div class="card-title">Sessions</div> <div class="card-title">Sessions</div>
<div class="card-sub">Active session keys and per-session overrides.</div> <div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </button>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div class="filters" style="margin-bottom: 12px;">
<label class="field"> <label class="field-inline">
<span>Active within (minutes)</span> <span>Active</span>
<input <input
style="width: 72px;"
placeholder="min"
.value=${props.activeMinutes} .value=${props.activeMinutes}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
@ -135,9 +234,10 @@ export function renderSessions(props: SessionsProps) {
})} })}
/> />
</label> </label>
<label class="field"> <label class="field-inline">
<span>Limit</span> <span>Limit</span>
<input <input
style="width: 64px;"
.value=${props.limit} .value=${props.limit}
@input=${(e: Event) => @input=${(e: Event) =>
props.onFiltersChange({ props.onFiltersChange({
@ -148,8 +248,7 @@ export function renderSessions(props: SessionsProps) {
})} })}
/> />
</label> </label>
<label class="field checkbox"> <label class="field-inline checkbox">
<span>Include global</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.includeGlobal} .checked=${props.includeGlobal}
@ -161,9 +260,9 @@ export function renderSessions(props: SessionsProps) {
includeUnknown: props.includeUnknown, includeUnknown: props.includeUnknown,
})} })}
/> />
<span>Global</span>
</label> </label>
<label class="field checkbox"> <label class="field-inline checkbox">
<span>Include unknown</span>
<input <input
type="checkbox" type="checkbox"
.checked=${props.includeUnknown} .checked=${props.includeUnknown}
@ -175,39 +274,102 @@ export function renderSessions(props: SessionsProps) {
includeUnknown: (e.target as HTMLInputElement).checked, includeUnknown: (e.target as HTMLInputElement).checked,
})} })}
/> />
<span>Unknown</span>
</label> </label>
</div> </div>
${ ${
props.error props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
: nothing : nothing
} }
<div class="muted" style="margin-top: 12px;"> <div class="data-table-wrapper">
${props.result ? `Store: ${props.result.path}` : ""} <div class="data-table-toolbar">
</div> <div class="data-table-search">
<input
<div class="table" style="margin-top: 16px;"> type="text"
<div class="table-head"> placeholder="Filter by key, label, kind…"
<div>Key</div> .value=${props.searchQuery}
<div>Label</div> @input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
<div>Kind</div> />
<div>Updated</div> </div>
<div>Tokens</div>
<div>Thinking</div>
<div>Verbose</div>
<div>Reasoning</div>
<div>Actions</div>
</div> </div>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
${sortHeader("key", "Key")}
<th>Label</th>
${sortHeader("kind", "Kind")}
${sortHeader("updated", "Updated")}
${sortHeader("tokens", "Tokens")}
<th>Thinking</th>
<th>Verbose</th>
<th>Reasoning</th>
<th style="width: 60px;"></th>
</tr>
</thead>
<tbody>
${
paginated.length === 0
? html`
<tr>
<td colspan="9" style="text-align: center; padding: 48px 16px; color: var(--muted)">
No sessions found.
</td>
</tr>
`
: paginated.map((row) =>
renderRow(
row,
props.basePath,
props.onPatch,
props.onDelete,
props.onActionsOpenChange,
props.actionsOpenKey,
props.loading,
),
)
}
</tbody>
</table>
</div>
${ ${
rows.length === 0 totalRows > 0
? html` ? html`
<div class="muted">No sessions found.</div> <div class="data-table-pagination">
<div class="data-table-pagination__info">
${page * props.pageSize + 1}-${Math.min((page + 1) * props.pageSize, totalRows)}
of ${totalRows} row${totalRows === 1 ? "" : "s"}
</div>
<div class="data-table-pagination__controls">
<select
style="height: 32px; padding: 0 8px; font-size: 13px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card);"
.value=${String(props.pageSize)}
@change=${(e: Event) =>
props.onPageSizeChange(Number((e.target as HTMLSelectElement).value))}
>
${PAGE_SIZES.map((s) => html`<option value=${s}>${s} per page</option>`)}
</select>
<button
?disabled=${page <= 0}
@click=${() => props.onPageChange(page - 1)}
>
Previous
</button>
<button
?disabled=${page >= totalPages - 1}
@click=${() => props.onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
` `
: rows.map((row) => : nothing
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)
} }
</div> </div>
</section> </section>
@ -219,6 +381,8 @@ function renderRow(
basePath: string, basePath: string,
onPatch: SessionsProps["onPatch"], onPatch: SessionsProps["onPatch"],
onDelete: SessionsProps["onDelete"], onDelete: SessionsProps["onDelete"],
onActionsOpenChange: (key: string | null) => void,
actionsOpenKey: string | null,
disabled: boolean, disabled: boolean,
) { ) {
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a"; const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
@ -234,36 +398,58 @@ function renderRow(
typeof row.displayName === "string" && row.displayName.trim().length > 0 typeof row.displayName === "string" && row.displayName.trim().length > 0
? row.displayName.trim() ? row.displayName.trim()
: null; : null;
const label = typeof row.label === "string" ? row.label.trim() : ""; const showDisplayName = Boolean(
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label); displayName &&
displayName !== row.key &&
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
);
const canLink = row.kind !== "global"; const canLink = row.kind !== "global";
const chatUrl = canLink const chatUrl = canLink
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
: null; : 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` return html`
<div class="table-row"> <tr>
<div class="mono session-key-cell"> <td>
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key} <div class="mono session-key-cell">
${showDisplayName ? html`<span class="muted session-key-display-name">${displayName}</span>` : nothing} ${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
</div> ${
<div> showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing
}
</div>
</td>
<td>
<input <input
.value=${row.label ?? ""} .value=${row.label ?? ""}
?disabled=${disabled} ?disabled=${disabled}
placeholder="(optional)" 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) => { @change=${(e: Event) => {
const value = (e.target as HTMLInputElement).value.trim(); const value = (e.target as HTMLInputElement).value.trim();
onPatch(row.key, { label: value || null }); onPatch(row.key, { label: value || null });
}} }}
/> />
</div> </td>
<div>${row.kind}</div> <td>
<div>${updated}</div> <span class="data-table-badge ${badgeClass}">${row.kind}</span>
<div>${formatSessionTokens(row)}</div> </td>
<div> <td>${updated}</td>
<td>${formatSessionTokens(row)}</td>
<td>
<select <select
?disabled=${disabled} ?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) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { onPatch(row.key, {
@ -278,10 +464,11 @@ function renderRow(
</option>`, </option>`,
)} )}
</select> </select>
</div> </td>
<div> <td>
<select <select
?disabled=${disabled} ?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) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { verboseLevel: value || null }); onPatch(row.key, { verboseLevel: value || null });
@ -294,10 +481,11 @@ function renderRow(
</option>`, </option>`,
)} )}
</select> </select>
</div> </td>
<div> <td>
<select <select
?disabled=${disabled} ?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) => { @change=${(e: Event) => {
const value = (e.target as HTMLSelectElement).value; const value = (e.target as HTMLSelectElement).value;
onPatch(row.key, { reasoningLevel: value || null }); onPatch(row.key, { reasoningLevel: value || null });
@ -310,12 +498,53 @@ function renderRow(
</option>`, </option>`,
)} )}
</select> </select>
</div> </td>
<div> <td>
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}> <div class="data-table-row-actions">
Delete <button
</button> type="button"
</div> class="data-table-row-actions__trigger"
</div> aria-label="Open menu"
@click=${(e: Event) => {
e.stopPropagation();
onActionsOpenChange(isMenuOpen ? null : row.key);
}}
>
${icons.moreHorizontal}
</button>
${
isMenuOpen
? html`
<div class="data-table-row-actions__menu">
${
canLink
? html`
<a
href=${chatUrl}
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
@click=${() => onActionsOpenChange(null)}
>
Open in Chat
</a>
`
: nothing
}
<button
type="button"
class="danger"
@click=${() => {
onActionsOpenChange(null);
onDelete(row.key);
}}
>
Delete
</button>
</div>
`
: nothing
}
</div>
</td>
</tr>
`; `;
} }

View File

@ -37,19 +37,8 @@ export function renderSkills(props: SkillsProps) {
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<div> <label class="field" style="flex: 1; min-width: 180px;">
<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 <input
.value=${props.filter} .value=${props.filter}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)} @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
@ -57,6 +46,9 @@ export function renderSkills(props: SkillsProps) {
/> />
</label> </label>
<div class="muted">${filtered.length} shown</div> <div class="muted">${filtered.length} shown</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div> </div>
${ ${

View File

@ -158,6 +158,7 @@ function renderDailyChartCompact(
return html` return html`
<div class="daily-chart-compact"> <div class="daily-chart-compact">
<div class="daily-chart-header"> <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"> <div class="chart-toggle small sessions-toggle">
<button <button
class="toggle-btn ${dailyChartMode === "total" ? "active" : ""}" class="toggle-btn ${dailyChartMode === "total" ? "active" : ""}"
@ -166,13 +167,12 @@ function renderDailyChartCompact(
Total Total
</button> </button>
<button <button
class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}" class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}
@click=${() => onDailyChartModeChange("by-type")} @click=${() => onDailyChartModeChange("by-type")}
> >
By Type By Type
</button> </button>
</div> </div>
<div class="card-title">Daily ${isTokenMode ? "Token" : "Cost"} Usage</div>
</div> </div>
<div class="daily-chart"> <div class="daily-chart">
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px"> <div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">

View File

@ -1,18 +1,4 @@
export const usageStylesPart1 = ` 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 ===== */ /* ===== FILTERS & HEADER ===== */
.usage-filters-inline { .usage-filters-inline {
display: flex; display: flex;

View File

@ -116,21 +116,21 @@ export const usageStylesPart2 = `
.daily-chart-header { .daily-chart-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 4px;
} }
/* ===== DAILY BAR CHART ===== */ /* ===== DAILY BAR CHART ===== */
.daily-chart { .daily-chart {
margin-top: 12px; margin-top: 8px;
} }
.daily-chart-bars { .daily-chart-bars {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
height: 200px; height: 200px;
gap: 4px; gap: 4px;
padding: 8px 4px 36px; padding: 4px 4px 30px;
} }
.daily-bar-wrapper { .daily-bar-wrapper {
flex: 1; flex: 1;
@ -666,21 +666,21 @@ export const usageStylesPart2 = `
line-height: 1.5; line-height: 1.5;
} }
/* ===== TWO COLUMN LAYOUT ===== */ /* ===== TWO ROWS: Daily+Breakdown, Sessions (each scrollable) ===== */
.usage-grid { .usage-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr;
gap: 18px; grid-template-rows: auto auto;
margin-top: 18px; gap: 14px;
align-items: stretch; margin-top: 14px;
}
.usage-grid-left {
display: flex;
flex-direction: column;
} }
.usage-grid-left,
.usage-grid-right { .usage-grid-right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-height: 360px;
overflow-y: auto;
overflow-x: hidden;
} }
/* ===== LEFT CARD (Daily + Breakdown) ===== */ /* ===== LEFT CARD (Daily + Breakdown) ===== */
@ -697,6 +697,6 @@ export const usageStylesPart2 = `
.usage-left-card .sessions-panel-title { .usage-left-card .sessions-panel-title {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
margin-bottom: 12px; margin-bottom: 8px;
} }
`; `;

View File

@ -2,14 +2,14 @@ export const usageStylesPart3 = `
/* ===== COMPACT DAILY CHART ===== */ /* ===== COMPACT DAILY CHART ===== */
.daily-chart-compact { .daily-chart-compact {
margin-bottom: 16px; margin-bottom: 10px;
} }
.daily-chart-compact .sessions-panel-title { .daily-chart-compact .sessions-panel-title {
margin-bottom: 8px; margin-bottom: 6px;
} }
.daily-chart-compact .daily-chart-bars { .daily-chart-compact .daily-chart-bars {
height: 100px; height: 100px;
padding-bottom: 20px; padding-bottom: 18px;
} }
/* ===== COMPACT COST BREAKDOWN ===== */ /* ===== COMPACT COST BREAKDOWN ===== */
@ -18,13 +18,17 @@ export const usageStylesPart3 = `
margin: 0; margin: 0;
background: transparent; background: transparent;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding-top: 12px; padding-top: 10px;
} }
.cost-breakdown-compact .cost-breakdown-header { .cost-breakdown-compact .cost-breakdown-header {
margin-bottom: 8px; margin-bottom: 6px;
} }
.cost-breakdown-compact .cost-breakdown-legend { .cost-breakdown-compact .cost-breakdown-legend {
gap: 12px; gap: 10px;
margin-top: 8px;
}
.cost-breakdown-compact .cost-breakdown-total {
margin-top: 6px;
} }
.cost-breakdown-compact .cost-breakdown-note { .cost-breakdown-compact .cost-breakdown-note {
display: none; display: none;
@ -41,7 +45,7 @@ export const usageStylesPart3 = `
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 6px;
} }
.sessions-card-title { .sessions-card-title {
font-weight: 600; font-weight: 600;
@ -55,8 +59,8 @@ export const usageStylesPart3 = `
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
margin: 8px 0 10px; margin: 6px 0 8px;
font-size: 12px; font-size: 12px;
color: var(--muted); color: var(--muted);
} }

View File

@ -447,11 +447,6 @@ export function renderUsage(props: UsageProps) {
return html` return html`
<style>${usageStylesString}</style> <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" : ""}"> <section class="card usage-header ${props.headerPinned ? "pinned" : ""}">
<div class="usage-header-row"> <div class="usage-header-row">
<div class="usage-header-title"> <div class="usage-header-title">