mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -22,7 +22,7 @@ Open the dashboard at `http://localhost:<port>` (or the gateway's configured UI
|
||||
- [ ] Light
|
||||
- [ ] OpenKnot (Aurora)
|
||||
- [ ] Field Manual
|
||||
- [ ] ClawDash (Chrome)
|
||||
- [ ] OpenClaw (Chrome)
|
||||
- [ ] Glass components (cards, panels, inputs) render correctly per theme
|
||||
- [ ] Theme persists across page reload
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const en: TranslationMap = {
|
||||
settings: "Settings",
|
||||
expand: "Expand sidebar",
|
||||
collapse: "Collapse sidebar",
|
||||
resize: "Resize sidebar",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agents",
|
||||
@@ -38,19 +39,19 @@ export const en: TranslationMap = {
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Manage agent workspaces, tools, and identities.",
|
||||
overview: "Gateway status, entry points, and a fast health read.",
|
||||
channels: "Manage channels and settings.",
|
||||
instances: "Presence beacons from connected clients and nodes.",
|
||||
sessions: "Inspect active sessions and adjust per-session defaults.",
|
||||
usage: "Monitor API usage and costs.",
|
||||
cron: "Schedule wakeups and recurring agent runs.",
|
||||
skills: "Manage skill availability and API key injection.",
|
||||
nodes: "Paired devices, capabilities, and command exposure.",
|
||||
chat: "Direct gateway chat session for quick interventions.",
|
||||
config: "Edit ~/.openclaw/openclaw.json safely.",
|
||||
debug: "Gateway snapshots, events, and manual RPC calls.",
|
||||
logs: "Live tail of the gateway file logs.",
|
||||
agents: "Workspaces, tools, identities.",
|
||||
overview: "Status, entry points, health.",
|
||||
channels: "Channels and settings.",
|
||||
instances: "Connected clients and nodes.",
|
||||
sessions: "Active sessions and defaults.",
|
||||
usage: "API usage and costs.",
|
||||
cron: "Wakeups and recurring runs.",
|
||||
skills: "Skills and API keys.",
|
||||
nodes: "Paired devices and commands.",
|
||||
chat: "Gateway chat for quick interventions.",
|
||||
config: "Edit openclaw.json.",
|
||||
debug: "Snapshots, events, RPC.",
|
||||
logs: "Live gateway logs.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
@@ -140,7 +141,7 @@ export const en: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "Gateway Dashboard",
|
||||
tokenPlaceholder: "paste gateway token",
|
||||
passwordPlaceholder: "optional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
|
||||
@@ -21,6 +21,7 @@ export const pt_BR: TranslationMap = {
|
||||
settings: "Configurações",
|
||||
expand: "Expandir barra lateral",
|
||||
collapse: "Recolher barra lateral",
|
||||
resize: "Redimensionar barra lateral",
|
||||
},
|
||||
tabs: {
|
||||
agents: "Agentes",
|
||||
@@ -38,19 +39,19 @@ export const pt_BR: TranslationMap = {
|
||||
logs: "Logs",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "Gerenciar espaços de trabalho, ferramentas e identidades de agentes.",
|
||||
overview: "Status do gateway, pontos de entrada e leitura rápida de saúde.",
|
||||
channels: "Gerenciar canais e configurações.",
|
||||
instances: "Beacons de presença de clientes e nós conectados.",
|
||||
sessions: "Inspecionar sessões ativas e ajustar padrões por sessão.",
|
||||
usage: "Monitorar uso e custos da API.",
|
||||
cron: "Agendar despertares e execuções recorrentes de agentes.",
|
||||
skills: "Gerenciar disponibilidade de habilidades e injeção de chaves de API.",
|
||||
nodes: "Dispositivos pareados, capacidades e exposição de comandos.",
|
||||
chat: "Sessão de chat direta com o gateway para intervenções rápidas.",
|
||||
config: "Editar ~/.openclaw/openclaw.json com segurança.",
|
||||
debug: "Snapshots do gateway, eventos e chamadas RPC manuais.",
|
||||
logs: "Acompanhamento ao vivo dos logs de arquivo do gateway.",
|
||||
agents: "Espaços, ferramentas, identidades.",
|
||||
overview: "Status, entrada, saúde.",
|
||||
channels: "Canais e configurações.",
|
||||
instances: "Clientes e nós conectados.",
|
||||
sessions: "Sessões ativas e padrões.",
|
||||
usage: "Uso e custos da API.",
|
||||
cron: "Despertares e execuções.",
|
||||
skills: "Habilidades e chaves API.",
|
||||
nodes: "Dispositivos e comandos.",
|
||||
chat: "Chat do gateway para intervenções rápidas.",
|
||||
config: "Editar openclaw.json.",
|
||||
debug: "Snapshots, eventos, RPC.",
|
||||
logs: "Logs ao vivo do gateway.",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
@@ -142,7 +143,7 @@ export const pt_BR: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "Painel do Gateway",
|
||||
tokenPlaceholder: "cole o token do gateway",
|
||||
passwordPlaceholder: "opcional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Desconectado do gateway.",
|
||||
|
||||
@@ -21,6 +21,7 @@ export const zh_CN: TranslationMap = {
|
||||
settings: "设置",
|
||||
expand: "展开侧边栏",
|
||||
collapse: "折叠侧边栏",
|
||||
resize: "调整侧边栏大小",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
@@ -38,19 +39,19 @@ export const zh_CN: TranslationMap = {
|
||||
logs: "日志",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "管理代理工作区、工具和身份。",
|
||||
overview: "网关状态、入口点和快速健康读取。",
|
||||
channels: "管理频道和设置。",
|
||||
instances: "来自已连接客户端和节点的在线信号。",
|
||||
sessions: "检查活动会话并调整每个会话的默认设置。",
|
||||
usage: "监控 API 使用情况和成本。",
|
||||
cron: "安排唤醒和重复的代理运行。",
|
||||
skills: "管理技能可用性和 API 密钥注入。",
|
||||
nodes: "配对设备、功能和命令公开。",
|
||||
chat: "用于快速干预的直接网关聊天会话。",
|
||||
config: "安全地编辑 ~/.openclaw/openclaw.json。",
|
||||
debug: "网关快照、事件和手动 RPC 调用。",
|
||||
logs: "网关文件日志的实时追踪。",
|
||||
agents: "工作区、工具、身份。",
|
||||
overview: "状态、入口点、健康。",
|
||||
channels: "频道和设置。",
|
||||
instances: "已连接客户端和节点。",
|
||||
sessions: "活动会话和默认设置。",
|
||||
usage: "API 使用情况和成本。",
|
||||
cron: "唤醒和重复运行。",
|
||||
skills: "技能和 API 密钥。",
|
||||
nodes: "配对设备和命令。",
|
||||
chat: "网关聊天,快速干预。",
|
||||
config: "编辑 openclaw.json。",
|
||||
debug: "快照、事件、RPC。",
|
||||
logs: "实时网关日志。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
@@ -139,7 +140,7 @@ export const zh_CN: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "网关仪表盘",
|
||||
tokenPlaceholder: "粘贴网关令牌",
|
||||
passwordPlaceholder: "可选",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已断开与网关的连接。",
|
||||
|
||||
@@ -21,6 +21,7 @@ export const zh_TW: TranslationMap = {
|
||||
settings: "設置",
|
||||
expand: "展開側邊欄",
|
||||
collapse: "折疊側邊欄",
|
||||
resize: "調整側邊欄大小",
|
||||
},
|
||||
tabs: {
|
||||
agents: "代理",
|
||||
@@ -38,19 +39,19 @@ export const zh_TW: TranslationMap = {
|
||||
logs: "日誌",
|
||||
},
|
||||
subtitles: {
|
||||
agents: "管理代理工作區、工具和身份。",
|
||||
overview: "網關狀態、入口點和快速健康讀取。",
|
||||
channels: "管理頻道和設置。",
|
||||
instances: "來自已連接客戶端和節點的在線信號。",
|
||||
sessions: "檢查活動會話並調整每個會話的默認設置。",
|
||||
usage: "監控 API 使用情況和成本。",
|
||||
cron: "安排喚醒和重複的代理運行。",
|
||||
skills: "管理技能可用性和 API 密鑰注入。",
|
||||
nodes: "配對設備、功能和命令公開。",
|
||||
chat: "用於快速干預的直接網關聊天會話。",
|
||||
config: "安全地編輯 ~/.openclaw/openclaw.json。",
|
||||
debug: "網關快照、事件和手動 RPC 調用。",
|
||||
logs: "網關文件日志的實時追蹤。",
|
||||
agents: "工作區、工具、身份。",
|
||||
overview: "狀態、入口點、健康。",
|
||||
channels: "頻道和設置。",
|
||||
instances: "已連接客戶端和節點。",
|
||||
sessions: "活動會話和默認設置。",
|
||||
usage: "API 使用情況和成本。",
|
||||
cron: "喚醒和重複運行。",
|
||||
skills: "技能和 API 密鑰。",
|
||||
nodes: "配對設備和命令。",
|
||||
chat: "網關聊天,快速干預。",
|
||||
config: "編輯 openclaw.json。",
|
||||
debug: "快照、事件、RPC。",
|
||||
logs: "實時網關日誌。",
|
||||
},
|
||||
overview: {
|
||||
access: {
|
||||
@@ -139,7 +140,7 @@ export const zh_TW: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "閘道儀表板",
|
||||
tokenPlaceholder: "貼上閘道令牌",
|
||||
passwordPlaceholder: "可選",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "已斷開與網關的連接。",
|
||||
|
||||
@@ -96,55 +96,55 @@
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
/* ─── Theme: light (Docs) — Warm Editorial Dark ─── */
|
||||
/* ─── Theme: light — Luxe Cream & Coral ─── */
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: dark;
|
||||
color-scheme: light;
|
||||
|
||||
--vscode-bg: #0e0c0e;
|
||||
--vscode-sidebar: #131012;
|
||||
--vscode-panel: #161214;
|
||||
--vscode-panel-border: rgba(255, 255, 255, 0.06);
|
||||
--vscode-surface: #1a1618;
|
||||
--vscode-hover: #201c1e;
|
||||
--vscode-contrast: #080608;
|
||||
--vscode-text: #d5d0cf;
|
||||
--vscode-muted: #7a7472;
|
||||
--vscode-subtle: #4a4442;
|
||||
--vscode-ghost: #1a1616;
|
||||
--vscode-accent: #ca3a29;
|
||||
--vscode-accent-alpha: rgba(202, 58, 41, 0.14);
|
||||
--vscode-selection: #3d1418;
|
||||
--vscode-success: #00d4aa;
|
||||
--vscode-danger: #ca3a29;
|
||||
--vscode-bg: #faf7f2;
|
||||
--vscode-sidebar: #f5f0e8;
|
||||
--vscode-panel: #fffef9;
|
||||
--vscode-panel-border: rgba(26, 22, 20, 0.08);
|
||||
--vscode-surface: #fffef9;
|
||||
--vscode-hover: #f0ebe3;
|
||||
--vscode-contrast: #f0ebe3;
|
||||
--vscode-text: #1a1614;
|
||||
--vscode-muted: #6b5d54;
|
||||
--vscode-subtle: #9c8f84;
|
||||
--vscode-ghost: #ebe6df;
|
||||
--vscode-accent: #c73526;
|
||||
--vscode-accent-alpha: rgba(199, 53, 38, 0.12);
|
||||
--vscode-selection: rgba(199, 53, 38, 0.18);
|
||||
--vscode-success: #0d9b7a;
|
||||
--vscode-danger: #c73526;
|
||||
|
||||
--kn-claw: #ca3a29;
|
||||
--kn-claw-bright: #fd8e2e;
|
||||
--kn-claw-dim: rgba(202, 58, 41, 0.12);
|
||||
--kn-claw-ember: #fb9231;
|
||||
--kn-claw-deep: #9a2d1f;
|
||||
--kn-ocean: #0e0c0e;
|
||||
--kn-ocean-bright: #201c1e;
|
||||
--kn-ocean-mid: #161214;
|
||||
--kn-ocean-dim: rgba(14, 12, 14, 0.8);
|
||||
--kn-ocean-deep: #0e0c0e;
|
||||
--kn-silver: #8a7e72;
|
||||
--kn-silver-bright: #c0b4a8;
|
||||
--kn-silver-dim: rgba(138, 126, 114, 0.12);
|
||||
--kn-bioluminescence: #00d4aa;
|
||||
--kn-warm-dark: #1a1416;
|
||||
--kn-void: #1a1416;
|
||||
--kn-claw: #c73526;
|
||||
--kn-claw-bright: #e85a4a;
|
||||
--kn-claw-dim: rgba(199, 53, 38, 0.14);
|
||||
--kn-claw-ember: #d94a3a;
|
||||
--kn-claw-deep: #9a2a1e;
|
||||
--kn-ocean: #faf7f2;
|
||||
--kn-ocean-bright: #fffef9;
|
||||
--kn-ocean-mid: #f5f0e8;
|
||||
--kn-ocean-dim: rgba(250, 247, 242, 0.9);
|
||||
--kn-ocean-deep: #f0ebe3;
|
||||
--kn-silver: #6b5d54;
|
||||
--kn-silver-bright: #1a1614;
|
||||
--kn-silver-dim: rgba(107, 93, 84, 0.12);
|
||||
--kn-bioluminescence: #0d9b7a;
|
||||
--kn-warm-dark: #1a1614;
|
||||
--kn-void: #ebe6df;
|
||||
|
||||
--glass-blur: 0px;
|
||||
--glass-saturate: 100%;
|
||||
--glass-bg: rgba(22, 18, 20, 0.95);
|
||||
--glass-bg-elevated: rgba(26, 22, 24, 0.96);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-border-hover: rgba(202, 58, 41, 0.25);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
--glass-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
--glass-shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--glass-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
--glass-blur: 12px;
|
||||
--glass-saturate: 110%;
|
||||
--glass-bg: rgba(255, 254, 249, 0.88);
|
||||
--glass-bg-elevated: rgba(255, 255, 255, 0.95);
|
||||
--glass-border: rgba(26, 22, 20, 0.1);
|
||||
--glass-border-hover: rgba(199, 53, 38, 0.35);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||
--glass-shadow-sm: 0 2px 12px rgba(26, 22, 20, 0.06), 0 1px 3px rgba(26, 22, 20, 0.04);
|
||||
--glass-shadow-md: 0 8px 32px rgba(26, 22, 20, 0.08), 0 2px 8px rgba(26, 22, 20, 0.04);
|
||||
--glass-shadow-lg: 0 20px 56px rgba(26, 22, 20, 0.12), 0 4px 16px rgba(26, 22, 20, 0.06);
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
@@ -491,6 +491,16 @@
|
||||
--agent-tab-hover-bg: var(--vscode-accent-alpha);
|
||||
}
|
||||
|
||||
/* Light theme semantic overrides (accent buttons need dark text) */
|
||||
:root[data-theme="light"] {
|
||||
--card-highlight: rgba(255, 255, 255, 0.85);
|
||||
--accent-foreground: #ffffff;
|
||||
--primary-foreground: #ffffff;
|
||||
--destructive-foreground: #ffffff;
|
||||
--focus-offset-color: var(--bg);
|
||||
--grid-line: rgba(26, 22, 20, 0.06);
|
||||
}
|
||||
|
||||
/* ─── Accessibility: High Contrast ─── */
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@@ -714,6 +724,20 @@ select {
|
||||
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 ─── */
|
||||
|
||||
:root[data-theme="clawdash"] body {
|
||||
|
||||
@@ -107,9 +107,12 @@
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.agent-chat__badge svg {
|
||||
.agent-chat__badge svg,
|
||||
.agent-chat__badge img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
object-fit: contain;
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
/* ─── Starter Cards ─── */
|
||||
@@ -239,6 +242,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--logo {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--logo svg,
|
||||
.agent-chat__avatar--logo img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
@@ -1124,10 +1138,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.chat-agent-bar__left {
|
||||
@@ -1145,143 +1160,29 @@
|
||||
}
|
||||
|
||||
.chat-agent-bar__name {
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chat-agent-select {
|
||||
background: color-mix(in srgb, var(--secondary) 70%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 20px 2px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 0 14px 0 0;
|
||||
cursor: pointer;
|
||||
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-position: right 4px center;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease;
|
||||
background-position: right 0 center;
|
||||
}
|
||||
|
||||
.chat-agent-select:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in srgb, var(--secondary) 90%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-agent-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* ─── Sessions Panel ─── */
|
||||
|
||||
.chat-sessions-panel {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-sessions-summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 8px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
@@ -124,6 +124,13 @@ img.chat-avatar {
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
/* Logo avatar (OpenClaw favicon) - contain to show full logo */
|
||||
img.chat-avatar.chat-avatar--logo {
|
||||
object-fit: contain;
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Minimal Bubble Design - dynamic width based on content */
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
flex-direction: column;
|
||||
flex: 1 1 0;
|
||||
height: 100%;
|
||||
min-height: 0; /* Allow flex shrinking */
|
||||
min-height: 0;
|
||||
/* Allow flex shrinking */
|
||||
overflow: hidden;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -21,18 +22,18 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -40,21 +41,23 @@
|
||||
.chat-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-session {
|
||||
min-width: 180px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
/* Chat thread - scrollable middle section, transparent */
|
||||
.chat-thread {
|
||||
flex: 1 1 0; /* Grow, shrink, and use 0 base for proper scrolling */
|
||||
flex: 1 1 0;
|
||||
/* Grow, shrink, and use 0 base for proper scrolling */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 10px 6px;
|
||||
padding: 6px 6px 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);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -151,9 +154,10 @@
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: auto; /* Push to bottom of flex container */
|
||||
padding: 14px 6px 6px;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
/* 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%);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
@@ -170,7 +174,8 @@
|
||||
border: 1px solid var(--border);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
align-self: flex-start; /* Don't stretch in flex column parent */
|
||||
align-self: flex-start;
|
||||
/* Don't stretch in flex column parent */
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
@@ -318,13 +323,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 120px;
|
||||
max-width: 260px;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.chat-controls__thinking {
|
||||
@@ -336,7 +341,7 @@
|
||||
|
||||
/* Icon button style */
|
||||
.btn--icon {
|
||||
padding: 6px !important;
|
||||
padding: 0 !important;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
@@ -347,12 +352,17 @@
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Controls separator */
|
||||
/* Controls separator — renders as a thin vertical divider */
|
||||
.chat-controls__separator {
|
||||
color: var(--border);
|
||||
font-size: 14px;
|
||||
margin: 0 2px;
|
||||
font-weight: 300;
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
font-size: 0;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.btn--icon:hover {
|
||||
@@ -371,6 +381,7 @@
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -379,9 +390,9 @@
|
||||
}
|
||||
|
||||
.chat-controls__session select {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
max-width: 260px;
|
||||
padding: 0 28px 0 10px;
|
||||
font-size: 13px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -390,16 +401,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
padding: 0 10px;
|
||||
height: 32px;
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.chat-session {
|
||||
min-width: 100px;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
@@ -408,11 +420,15 @@
|
||||
|
||||
.chat-controls {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.chat-controls__session {
|
||||
min-width: 100px;
|
||||
max-width: 180px;
|
||||
min-width: 80px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.chat-controls__separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
.chat-sidebar {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
min-width: 200px;
|
||||
container-type: inline-size;
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -77,11 +78,14 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-markdown {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sidebar-markdown pre {
|
||||
@@ -97,6 +101,38 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Minimal state when sidebar is narrow: hide content, show expand hint */
|
||||
@container (max-width: 260px) {
|
||||
.chat-sidebar .sidebar-header {
|
||||
padding: 6px 8px;
|
||||
border-bottom: none;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-sidebar .sidebar-content::before {
|
||||
content: "← Drag to expand";
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: Full-screen modal */
|
||||
@media (max-width: 768px) {
|
||||
.chat-split-container--open {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
height: calc(100vh - 160px);
|
||||
margin: 0 -16px -16px;
|
||||
margin: -16px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
=========================================== */
|
||||
|
||||
.shell {
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 240px;
|
||||
--shell-topbar-height: 62px;
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 52px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
@@ -80,8 +80,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
height: var(--shell-topbar-height);
|
||||
background: var(--topbar-bg);
|
||||
backdrop-filter: saturate(var(--glass-saturate)) blur(var(--glass-blur));
|
||||
@@ -102,7 +102,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -142,17 +142,17 @@
|
||||
.topbar-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
min-width: 200px;
|
||||
max-width: 340px;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
flex: 1;
|
||||
height: 34px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--secondary) 60%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
@@ -220,10 +220,10 @@
|
||||
.topbar-connection {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
@@ -262,10 +262,9 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: none;
|
||||
@@ -279,8 +278,8 @@
|
||||
}
|
||||
|
||||
.topbar-redact svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.topbar-redact:hover {
|
||||
@@ -290,45 +289,40 @@
|
||||
}
|
||||
|
||||
.topbar-redact--active {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 4px 10px;
|
||||
color: var(--warn);
|
||||
background: var(--warn-subtle);
|
||||
}
|
||||
|
||||
.topbar-redact--active:hover {
|
||||
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 {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Topbar theme select sizing */
|
||||
|
||||
/* Topbar theme toggle sizing */
|
||||
|
||||
.topbar-status .theme-toggle {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
.topbar-status .theme-select {
|
||||
height: 26px;
|
||||
min-width: 82px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
Navigation Sidebar
|
||||
=========================================== */
|
||||
|
||||
.sidebar {
|
||||
.shell-nav {
|
||||
grid-area: nav;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
@@ -347,13 +341,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--chat-focus .sidebar {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
.shell--chat-focus .sidebar,
|
||||
.shell--chat-focus .sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
@@ -395,6 +385,21 @@
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.sidebar--collapsed .nav-item--active::before {
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 120% 28px at 50% -2px,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent) 40%,
|
||||
transparent 100%
|
||||
),
|
||||
radial-gradient(
|
||||
ellipse 60% 100% at -4px 50%,
|
||||
color-mix(in srgb, var(--accent) 28%, transparent) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.sidebar--collapsed .sidebar-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -409,24 +414,54 @@
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Sidebar resizer handle */
|
||||
.sidebar-resizer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
/* Hit area extends beyond visible handle for easier grabbing */
|
||||
margin-right: -3px;
|
||||
}
|
||||
|
||||
.sidebar-resizer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--glass-border);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-resizer:hover::before,
|
||||
.sidebar-resizer:active::before {
|
||||
background: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
/* Sidebar header (brand + collapse) */
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 8px;
|
||||
gap: 0;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
flex: 2;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
max-height: 28px;
|
||||
|
||||
padding-left: 10px;
|
||||
@@ -452,13 +487,19 @@
|
||||
line-height: 1.1;
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
flex: 1;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
@@ -595,6 +636,7 @@
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
@@ -667,6 +709,33 @@
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.nav-item--active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(
|
||||
ellipse 28px 120% at -2px 50%,
|
||||
color-mix(in srgb, var(--accent) 38%, transparent) 0%,
|
||||
color-mix(in srgb, var(--accent) 14%, transparent) 40%,
|
||||
transparent 100%
|
||||
);
|
||||
opacity: 0;
|
||||
animation: nav-glow-in 0.4s ease-out 0.05s forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes nav-glow-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item--active .nav-item__icon {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
@@ -680,11 +749,17 @@
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-footer__docs-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
justify-content: flex-start;
|
||||
padding: 2px 12px 6px;
|
||||
}
|
||||
|
||||
.sidebar-version__text {
|
||||
@@ -708,7 +783,7 @@
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
padding: 14px 18px 36px;
|
||||
padding: 12px 14px 24px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -716,13 +791,13 @@
|
||||
}
|
||||
|
||||
.content > * + * {
|
||||
margin-top: 24px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.content--chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
@@ -734,10 +809,12 @@
|
||||
/* Content header */
|
||||
.content-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 0;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transform-origin: top center;
|
||||
transition:
|
||||
@@ -745,30 +822,30 @@
|
||||
transform var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
max-height var(--shell-focus-duration) var(--shell-focus-ease),
|
||||
padding var(--shell-focus-duration) var(--shell-focus-ease);
|
||||
max-height: 64px;
|
||||
max-height: 36px;
|
||||
}
|
||||
|
||||
.shell--chat-focus .content-header {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
max-height: 0px;
|
||||
max-height: 0;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.2;
|
||||
line-height: 1.25;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-top: 2px;
|
||||
margin-top: 1px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@@ -783,10 +860,13 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.content--chat .content-header > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -796,6 +876,66 @@
|
||||
|
||||
.content--chat .chat-controls {
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
/* Chat controls in header — uniform 32px height across all controls */
|
||||
.content-header .btn--icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.content-header .btn--icon svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__session select {
|
||||
height: 32px;
|
||||
line-height: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 0 28px 0 10px;
|
||||
background-position: right 8px center;
|
||||
border-radius: var(--radius-md);
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__separator {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
font-size: 0;
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.content-header .chat-controls__thinking {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ===========================================
|
||||
@@ -804,7 +944,7 @@
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
@@ -817,32 +957,32 @@
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.note-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -852,58 +992,14 @@
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.shell {
|
||||
--shell-pad: 12px;
|
||||
--shell-gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
grid-template-areas:
|
||||
"topbar"
|
||||
"nav"
|
||||
"content";
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
--shell-pad: 10px;
|
||||
--shell-gap: 10px;
|
||||
--shell-nav-width: 200px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
padding: 12px 14px;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-search__kbd {
|
||||
@@ -914,6 +1010,30 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10px 12px 20px;
|
||||
}
|
||||
|
||||
.content > * + * {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.grid-cols-2,
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-head,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -2,60 +2,10 @@
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
/* Tablet: keep side nav vertical, narrow sidebar */
|
||||
@media (max-width: 1100px) {
|
||||
.sidebar {
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
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;
|
||||
.shell {
|
||||
--shell-nav-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +14,7 @@
|
||||
.shell {
|
||||
--shell-pad: 8px;
|
||||
--shell-gap: 8px;
|
||||
--shell-nav-width: 180px;
|
||||
}
|
||||
|
||||
/* Topbar */
|
||||
@@ -142,8 +93,12 @@
|
||||
|
||||
/* Content — compact header on chat, hide on other tabs */
|
||||
.content-header {
|
||||
padding: 0;
|
||||
max-height: 48px;
|
||||
height: 64px;
|
||||
min-height: 64px;
|
||||
padding: 12px 0;
|
||||
/* This controls the height of the content header on mobile */
|
||||
max-height: 64px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.content:not(.content--chat) .content-header {
|
||||
@@ -151,7 +106,7 @@
|
||||
}
|
||||
|
||||
.content--chat .page-title {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.content--chat .page-sub {
|
||||
@@ -159,8 +114,8 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 4px 16px;
|
||||
gap: 12px;
|
||||
padding: 4px 6px 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
@@ -226,22 +181,7 @@
|
||||
|
||||
/* Chat */
|
||||
.chat-agent-bar {
|
||||
padding: 4px 8px;
|
||||
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;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
@@ -366,18 +306,10 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.theme-btn svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.theme-select {
|
||||
height: 26px;
|
||||
min-width: 78px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,12 +372,9 @@
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
.theme-select {
|
||||
height: 24px;
|
||||
min-width: 72px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,11 +170,6 @@ export function connectGateway(host: GatewayHost) {
|
||||
return;
|
||||
}
|
||||
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)
|
||||
if (code !== 1012) {
|
||||
host.lastError = `disconnected (${code}): ${reason || "no reason"}`;
|
||||
|
||||
@@ -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 sessionOptions = resolveSessionOptions(
|
||||
state.sessionKey,
|
||||
state.sessionsResult,
|
||||
mainSessionKey,
|
||||
);
|
||||
return html`
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatControls(state: AppViewState) {
|
||||
const disableThinkingToggle = state.onboarding;
|
||||
const disableFocusToggle = state.onboarding;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`
|
||||
<svg
|
||||
width="18"
|
||||
@@ -131,43 +172,6 @@ export function renderChatControls(state: AppViewState) {
|
||||
`;
|
||||
return html`
|
||||
<div class="chat-controls">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm btn--icon"
|
||||
?disabled=${state.chatLoading || !state.connected}
|
||||
@@ -396,55 +400,30 @@ function resolveSessionOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
type ThemeOption = { id: ThemeMode; label: string; iconKey: keyof typeof icons };
|
||||
type ThemeOption = { id: ThemeMode; label: string };
|
||||
const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ id: "dark", label: "Dark", iconKey: "monitor" },
|
||||
{ id: "light", label: "Light", iconKey: "book" },
|
||||
{ id: "openknot", label: "Knot", iconKey: "zap" },
|
||||
{ id: "fieldmanual", label: "Field", iconKey: "terminal" },
|
||||
{ id: "clawdash", label: "Chrome", iconKey: "settings" },
|
||||
{ id: "dark", label: "Claw" },
|
||||
{ id: "light", label: "Light" },
|
||||
{ id: "openknot", label: "Knot" },
|
||||
{ id: "fieldmanual", label: "Field" },
|
||||
{ id: "clawdash", label: "Chrome" },
|
||||
];
|
||||
|
||||
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`
|
||||
<div
|
||||
class="theme-toggle"
|
||||
@mouseleave=${handleCollapse}
|
||||
@focusout=${(e: FocusEvent) => {
|
||||
const toggle = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
if (!toggle.contains(document.activeElement)) {
|
||||
handleCollapse();
|
||||
}
|
||||
});
|
||||
<select
|
||||
class="theme-select"
|
||||
.value=${state.theme}
|
||||
aria-label="Theme"
|
||||
title="Theme"
|
||||
@change=${(e: Event) => {
|
||||
const select = e.target as HTMLSelectElement;
|
||||
const next = select.value as ThemeMode;
|
||||
const context: ThemeTransitionContext = { element: select };
|
||||
state.setTheme(next, context);
|
||||
}}
|
||||
>
|
||||
${state.themeOrder.map((id) => {
|
||||
const opt = THEME_OPTIONS.find((o) => o.id === id)!;
|
||||
return html`
|
||||
<button
|
||||
class="theme-btn ${state.theme === id ? "active" : ""}"
|
||||
@click=${applyTheme(id)}
|
||||
aria-pressed=${state.theme === id}
|
||||
title=${opt.label}
|
||||
>
|
||||
${icons[opt.iconKey]}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${THEME_OPTIONS.map((opt) => html`<option value=${opt.id}>${opt.label}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChatAvatar } from "./app-chat.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 { loadAgentFileContent, loadAgentFiles, saveAgentFile } from "./controllers/agent-files.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||
@@ -59,7 +64,6 @@ import "./components/dashboard-header.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import { renderAgents } from "./views/agents.ts";
|
||||
import { renderBottomTabs } from "./views/bottom-tabs.ts";
|
||||
import { renderChannels } from "./views/channels.ts";
|
||||
import { renderChat } from "./views/chat.ts";
|
||||
import { renderCommandPalette } from "./views/command-palette.ts";
|
||||
@@ -79,6 +83,33 @@ import { renderSkills } from "./views/skills.ts";
|
||||
const AVATAR_DATA_RE = /^data:/i;
|
||||
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||
|
||||
const NAV_WIDTH_MIN = 180;
|
||||
const NAV_WIDTH_MAX = 400;
|
||||
|
||||
function handleNavResizeStart(e: MouseEvent, state: AppViewState) {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = state.settings.navWidth;
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const next = Math.round(Math.min(NAV_WIDTH_MAX, Math.max(NAV_WIDTH_MIN, startWidth + delta)));
|
||||
state.applySettings({ ...state.settings, navWidth: next });
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
|
||||
const list = state.agentsList?.agents ?? [];
|
||||
const parsed = parseAgentSessionKey(state.sessionKey);
|
||||
@@ -140,11 +171,15 @@ export function renderApp(state: AppViewState) {
|
||||
onNavigate: (tab) => {
|
||||
state.setTab(tab as import("./navigation.ts").Tab);
|
||||
},
|
||||
onSlashCommand: (_cmd) => {
|
||||
onSlashCommand: (cmd) => {
|
||||
state.setTab("chat" as import("./navigation.ts").Tab);
|
||||
state.chatMessage = cmd.endsWith(" ") ? cmd : `${cmd} `;
|
||||
},
|
||||
})}
|
||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""} ${state.onboarding ? "shell--onboarding" : ""}">
|
||||
<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">
|
||||
<dashboard-header .tab=${state.tab}></dashboard-header>
|
||||
<button
|
||||
@@ -159,30 +194,6 @@ export function renderApp(state: AppViewState) {
|
||||
<kbd class="topbar-search__kbd">⌘K</kbd>
|
||||
</button>
|
||||
<div class="topbar-status">
|
||||
<button
|
||||
class="topbar-redact ${state.streamMode ? "topbar-redact--active" : ""}"
|
||||
@click=${() => {
|
||||
state.streamMode = !state.streamMode;
|
||||
try {
|
||||
localStorage.setItem("openclaw:stream-mode", String(state.streamMode));
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}}
|
||||
title="${state.streamMode ? "Sensitive data hidden — click to reveal" : "Sensitive data visible — click to hide"}"
|
||||
aria-label="Toggle redaction"
|
||||
aria-pressed=${state.streamMode}
|
||||
>
|
||||
${state.streamMode ? icons.eye : icons.eyeOff}
|
||||
${
|
||||
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" : ""}">
|
||||
<span class="topbar-connection__dot"></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)}
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell-nav">
|
||||
<aside class="sidebar ${state.settings.navCollapsed ? "sidebar--collapsed" : ""}">
|
||||
<div class="sidebar-header">
|
||||
${
|
||||
@@ -256,42 +268,61 @@ export function renderApp(state: AppViewState) {
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a
|
||||
class="nav-item nav-item--external"
|
||||
href="https://docs.openclaw.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="${t("common.docs")} (opens in new tab)"
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`
|
||||
<div class="sidebar-footer__docs-block">
|
||||
<a
|
||||
class="nav-item nav-item--external"
|
||||
href="https://docs.openclaw.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="${t("common.docs")} (opens in new tab)"
|
||||
>
|
||||
<span class="nav-item__icon" aria-hidden="true">${icons.book}</span>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`
|
||||
<span class="nav-item__text">${t("common.docs")}</span>
|
||||
<span class="nav-item__external-icon">${icons.externalLink}</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</a>
|
||||
${(() => {
|
||||
const snapshot = state.hello?.snapshot as { server?: { version?: string } } | undefined;
|
||||
const version = snapshot?.server?.version ?? "";
|
||||
return version
|
||||
? html`
|
||||
<div class="sidebar-version" title=${`v${version}`}>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`<span class="sidebar-version__text">v${version}</span>`
|
||||
: html`
|
||||
<span class="sidebar-version__dot"></span>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing;
|
||||
})()}
|
||||
: nothing
|
||||
}
|
||||
</a>
|
||||
${(() => {
|
||||
const snapshot = state.hello?.snapshot as
|
||||
| { server?: { version?: string } }
|
||||
| undefined;
|
||||
const version = snapshot?.server?.version ?? "";
|
||||
return version
|
||||
? html`
|
||||
<div class="sidebar-version" title=${`v${version}`}>
|
||||
${
|
||||
!state.settings.navCollapsed
|
||||
? html`<span class="sidebar-version__text">v${version}</span>`
|
||||
: html`
|
||||
<span class="sidebar-version__dot"></span>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
${
|
||||
!state.settings.navCollapsed && !chatFocus
|
||||
? html`
|
||||
<div
|
||||
class="sidebar-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="${t("nav.resize")}"
|
||||
title="${t("nav.resize")}"
|
||||
@mousedown=${(ev: MouseEvent) => handleNavResizeStart(ev, state)}
|
||||
></div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<main class="content ${isChat ? "content--chat" : ""}">
|
||||
${
|
||||
state.updateAvailable
|
||||
@@ -308,8 +339,14 @@ export function renderApp(state: AppViewState) {
|
||||
}
|
||||
<section class="content-header">
|
||||
<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 class="page-meta">
|
||||
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
|
||||
@@ -431,12 +468,37 @@ export function renderApp(state: AppViewState) {
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
basePath: state.basePath,
|
||||
searchQuery: state.sessionsSearchQuery,
|
||||
sortColumn: state.sessionsSortColumn,
|
||||
sortDir: state.sessionsSortDir,
|
||||
page: state.sessionsPage,
|
||||
pageSize: state.sessionsPageSize,
|
||||
actionsOpenKey: state.sessionsActionsOpenKey,
|
||||
onFiltersChange: (next) => {
|
||||
state.sessionsFilterActive = next.activeMinutes;
|
||||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
},
|
||||
onSearchChange: (q) => {
|
||||
state.sessionsSearchQuery = q;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onSortChange: (col, dir) => {
|
||||
state.sessionsSortColumn = col;
|
||||
state.sessionsSortDir = dir;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onPageChange: (p) => {
|
||||
state.sessionsPage = p;
|
||||
},
|
||||
onPageSizeChange: (s) => {
|
||||
state.sessionsPageSize = s;
|
||||
state.sessionsPage = 0;
|
||||
},
|
||||
onActionsOpenChange: (key) => {
|
||||
state.sessionsActionsOpenKey = key;
|
||||
},
|
||||
onRefresh: () => loadSessions(state),
|
||||
onPatch: (key, patch) => patchSession(state, key, patch),
|
||||
onDelete: (key) => deleteSessionAndRefresh(state, key),
|
||||
@@ -478,7 +540,7 @@ export function renderApp(state: AppViewState) {
|
||||
${
|
||||
state.tab === "agents"
|
||||
? renderAgents({
|
||||
basePath: state.basePath,
|
||||
basePath: state.basePath ?? "",
|
||||
loading: state.agentsLoading,
|
||||
error: state.agentsError,
|
||||
agentsList: state.agentsList,
|
||||
@@ -521,10 +583,6 @@ export function renderApp(state: AppViewState) {
|
||||
agentId: state.agentSkillsAgentId,
|
||||
filter: state.skillsFilter,
|
||||
},
|
||||
sidebarFilter: state.agentsSidebarFilter,
|
||||
onSidebarFilterChange: (value) => {
|
||||
state.agentsSidebarFilter = value;
|
||||
},
|
||||
onRefresh: async () => {
|
||||
await loadAgents(state);
|
||||
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
@@ -1060,6 +1118,7 @@ export function renderApp(state: AppViewState) {
|
||||
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
|
||||
assistantName: state.assistantName,
|
||||
assistantAvatar: state.assistantAvatar,
|
||||
basePath: state.basePath ?? "",
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -1151,10 +1210,7 @@ export function renderApp(state: AppViewState) {
|
||||
</main>
|
||||
${renderExecApprovalPrompt(state)}
|
||||
${renderGatewayUrlConfirmation(state)}
|
||||
${renderBottomTabs({
|
||||
activeTab: state.tab,
|
||||
onTabChange: (tab) => state.setTab(tab),
|
||||
})}
|
||||
${nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ const createHost = (tab: Tab): SettingsHost => ({
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
navWidth: 220,
|
||||
},
|
||||
theme: "dark",
|
||||
themeResolved: "dark",
|
||||
|
||||
@@ -269,7 +269,7 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = resolved;
|
||||
root.style.colorScheme = "dark";
|
||||
root.style.colorScheme = resolved === "light" ? "light" : "dark";
|
||||
}
|
||||
|
||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||
|
||||
@@ -146,7 +146,6 @@ export type AppViewState = {
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
agentsSidebarFilter: string;
|
||||
sessionsLoading: boolean;
|
||||
sessionsResult: SessionsListResult | null;
|
||||
sessionsError: string | null;
|
||||
@@ -154,6 +153,12 @@ export type AppViewState = {
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
sessionsSearchQuery: string;
|
||||
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
sessionsSortDir: "asc" | "desc";
|
||||
sessionsPage: number;
|
||||
sessionsPageSize: number;
|
||||
sessionsActionsOpenKey: string | null;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
|
||||
@@ -231,7 +231,6 @@ export class OpenClawApp extends LitElement {
|
||||
@state() agentSkillsError: string | null = null;
|
||||
@state() agentSkillsReport: SkillStatusReport | null = null;
|
||||
@state() agentSkillsAgentId: string | null = null;
|
||||
@state() agentsSidebarFilter = "";
|
||||
|
||||
@state() sessionsLoading = false;
|
||||
@state() sessionsResult: SessionsListResult | null = null;
|
||||
@@ -240,6 +239,12 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsFilterLimit = "120";
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
@state() sessionsSearchQuery = "";
|
||||
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
|
||||
@state() sessionsSortDir: "asc" | "desc" = "desc";
|
||||
@state() sessionsPage = 0;
|
||||
@state() sessionsPageSize = 10;
|
||||
@state() sessionsActionsOpenKey: string | null = null;
|
||||
|
||||
@state() usageLoading = false;
|
||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@@ -464,12 +469,6 @@ export class OpenClawApp extends LitElement {
|
||||
return [active, ...rest];
|
||||
}
|
||||
|
||||
handleThemeToggleCollapse() {
|
||||
setTimeout(() => {
|
||||
this.themeOrder = this.buildThemeOrder(this.theme);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||
import { agentLogoUrl } from "../views/agents-utils.ts";
|
||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||
import {
|
||||
extractTextCached,
|
||||
@@ -56,10 +57,10 @@ function extractImages(message: unknown): ImageBlock[] {
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
@@ -76,6 +77,7 @@ export function renderStreamingGroup(
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
basePath?: string,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
@@ -85,7 +87,7 @@ export function renderStreamingGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
@@ -112,6 +114,7 @@ export function renderMessageGroup(
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
basePath?: string;
|
||||
onDelete?: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -132,10 +135,14 @@ export function renderMessageGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(group.role, {
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
${renderAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
},
|
||||
opts.basePath,
|
||||
)}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
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 assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
@@ -195,9 +206,28 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
/* Use OpenClaw logo instead of emoji (e.g. ✨) for assistant avatar */
|
||||
const logoUrl = basePath ? agentLogoUrl(basePath) : "";
|
||||
if (logoUrl) {
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${logoUrl}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
||||
}
|
||||
|
||||
/* Assistant with no custom avatar: use logo when basePath available */
|
||||
if (normalized === "assistant" && basePath) {
|
||||
const logoUrl = agentLogoUrl(basePath);
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${logoUrl}"
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
|
||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export class DashboardHeader extends LitElement {
|
||||
class="dashboard-header__breadcrumb-link"
|
||||
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
|
||||
>
|
||||
ClawDash
|
||||
OpenClaw
|
||||
</span>
|
||||
<span class="dashboard-header__breadcrumb-sep">›</span>
|
||||
<span class="dashboard-header__breadcrumb-current">${label}</span>
|
||||
|
||||
@@ -109,13 +109,6 @@ export class GatewayBrowserClient {
|
||||
this.ws = null;
|
||||
this.flushPending(new Error(`gateway closed (${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.ws.addEventListener("error", () => {
|
||||
|
||||
@@ -334,6 +334,31 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
lobster: html`
|
||||
<svg viewBox="0 0 120 120" fill="none">
|
||||
<defs>
|
||||
<linearGradient id="lob-g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff4d4d" />
|
||||
<stop offset="100%" stop-color="#991b1b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M60 10C30 10 15 35 15 55C15 75 30 95 45 100L45 110L55 110L55 100C55 100 60 102 65 100L65 110L75 110L75 100C90 95 105 75 105 55C105 35 90 10 60 10Z"
|
||||
fill="url(#lob-g)"
|
||||
/>
|
||||
<path d="M20 45C5 40 0 50 5 60C10 70 20 65 25 55C28 48 25 45 20 45Z" fill="url(#lob-g)" />
|
||||
<path
|
||||
d="M100 45C115 40 120 50 115 60C110 70 100 65 95 55C92 48 95 45 100 45Z"
|
||||
fill="url(#lob-g)"
|
||||
/>
|
||||
<path d="M45 15Q35 5 30 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<path d="M75 15Q85 5 90 8" stroke="#ff4d4d" stroke-width="3" stroke-linecap="round" />
|
||||
<circle cx="45" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="75" cy="35" r="6" fill="#050810" />
|
||||
<circle cx="46" cy="34" r="2.5" fill="#00e5cc" />
|
||||
<circle cx="76" cy="34" r="2.5" fill="#00e5cc" />
|
||||
</svg>
|
||||
`,
|
||||
refresh: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
|
||||
@@ -369,6 +394,21 @@ export const icons = {
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
`,
|
||||
moreHorizontal: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
<circle cx="6" cy="12" r="1.5" />
|
||||
<circle cx="18" cy="12" r="1.5" />
|
||||
</svg>
|
||||
`,
|
||||
arrowUpDown: html`
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="m21 16-4 4-4-4" />
|
||||
<path d="M17 20V4" />
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
</svg>
|
||||
`,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type UiSettings = {
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (180–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
locale?: string;
|
||||
};
|
||||
@@ -33,6 +34,7 @@ export function loadSettings(): UiSettings {
|
||||
chatShowThinking: true,
|
||||
splitRatio: 0.6,
|
||||
navCollapsed: false,
|
||||
navWidth: 220,
|
||||
navGroupsCollapsed: {},
|
||||
};
|
||||
|
||||
@@ -74,6 +76,10 @@ export function loadSettings(): UiSettings {
|
||||
: defaults.splitRatio,
|
||||
navCollapsed:
|
||||
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
|
||||
navWidth:
|
||||
typeof parsed.navWidth === "number" && parsed.navWidth >= 180 && parsed.navWidth <= 400
|
||||
? parsed.navWidth
|
||||
: defaults.navWidth,
|
||||
navGroupsCollapsed:
|
||||
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
|
||||
? parsed.navGroupsCollapsed
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type { AgentsFilesListResult, AgentsListResult } from "../types.ts";
|
||||
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
|
||||
import {
|
||||
buildModelOptions,
|
||||
normalizeModelValue,
|
||||
@@ -13,9 +13,13 @@ import type { AgentsPanel } from "./agents.ts";
|
||||
|
||||
export function renderAgentOverview(params: {
|
||||
agent: AgentsListResult["agents"][number];
|
||||
basePath: string;
|
||||
defaultId: string | null;
|
||||
configForm: Record<string, unknown> | null;
|
||||
agentFilesList: AgentsFilesListResult | null;
|
||||
agentIdentity: AgentIdentityResult | null;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
configLoading: boolean;
|
||||
configSaving: boolean;
|
||||
configDirty: boolean;
|
||||
@@ -77,11 +81,12 @@ export function renderAgentOverview(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackSummary = fallbackChips.length > 0 ? `${fallbackChips.length} configured` : "none";
|
||||
|
||||
return html`
|
||||
<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="label">Workspace</div>
|
||||
<div>
|
||||
@@ -101,25 +106,23 @@ export function renderAgentOverview(params: {
|
||||
<div class="label">Skills Filter</div>
|
||||
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
|
||||
</div>
|
||||
<div class="agent-kv">
|
||||
<div class="label">Fallbacks</div>
|
||||
<div class="mono">${fallbackSummary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${
|
||||
configDirty
|
||||
? 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
|
||||
}
|
||||
|
||||
<div class="agent-model-select">
|
||||
<div class="row" style="gap: 12px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<label class="field" style="min-width: 240px; flex: 1;">
|
||||
<div class="agent-model-select" style="margin-top: 20px;">
|
||||
<div class="label">Model Selection</div>
|
||||
<div class="agent-model-fields">
|
||||
<label class="field">
|
||||
<span>Primary model${isDefault ? " (default)" : ""}</span>
|
||||
<select
|
||||
.value=${effectivePrimary ?? ""}
|
||||
?disabled=${disabled}
|
||||
@change=${(e: Event) =>
|
||||
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
|
||||
@@ -136,7 +139,7 @@ export function renderAgentOverview(params: {
|
||||
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
|
||||
</select>
|
||||
</label>
|
||||
<div class="field" style="min-width: 240px; flex: 1;">
|
||||
<div class="field">
|
||||
<span>Fallbacks</span>
|
||||
<div class="agent-chip-input" @click=${(e: Event) => {
|
||||
const container = e.currentTarget as HTMLElement;
|
||||
@@ -173,18 +176,19 @@ export function renderAgentOverview(params: {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-model-actions">
|
||||
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
|
||||
Reload Config
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${configSaving || !configDirty}
|
||||
@click=${onConfigSave}
|
||||
>
|
||||
${configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-model-actions">
|
||||
<button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
|
||||
Reload Config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${configSaving || !configDirty}
|
||||
@click=${onConfigSave}
|
||||
>
|
||||
${configSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -411,10 +411,7 @@ export function buildModelOptions(
|
||||
<option value="" disabled>No configured models</option>
|
||||
`;
|
||||
}
|
||||
return options.map(
|
||||
(option) =>
|
||||
html`<option value=${option.value} ?selected=${current === option.value}>${option.label}</option>`,
|
||||
);
|
||||
return options.map((option) => html`<option value=${option.value}>${option.label}</option>`);
|
||||
}
|
||||
|
||||
type CompiledPattern =
|
||||
|
||||
@@ -15,14 +15,7 @@ import {
|
||||
renderAgentCron,
|
||||
} from "./agents-panels-status-files.ts";
|
||||
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
||||
import {
|
||||
agentAvatarHue,
|
||||
agentBadgeText,
|
||||
agentLogoUrl,
|
||||
buildAgentContext,
|
||||
normalizeAgentLabel,
|
||||
resolveAgentAvatarUrl,
|
||||
} from "./agents-utils.ts";
|
||||
import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
|
||||
|
||||
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
|
||||
@@ -80,8 +73,6 @@ export type AgentsProps = {
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
agentSkills: AgentSkillsState;
|
||||
sidebarFilter: string;
|
||||
onSidebarFilterChange: (value: string) => void;
|
||||
onRefresh: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectPanel: (panel: AgentsPanel) => void;
|
||||
@@ -115,14 +106,6 @@ export function renderAgents(props: AgentsProps) {
|
||||
? (agents.find((agent) => agent.id === selectedId) ?? null)
|
||||
: null;
|
||||
|
||||
const sidebarFilter = props.sidebarFilter.trim().toLowerCase();
|
||||
const filteredAgents = sidebarFilter
|
||||
? agents.filter((agent) => {
|
||||
const label = normalizeAgentLabel(agent).toLowerCase();
|
||||
return label.includes(sidebarFilter) || agent.id.toLowerCase().includes(sidebarFilter);
|
||||
})
|
||||
: agents;
|
||||
|
||||
const channelEntryCount = props.channels.snapshot
|
||||
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
|
||||
: null;
|
||||
@@ -138,73 +121,81 @@ export function renderAgents(props: AgentsProps) {
|
||||
|
||||
return html`
|
||||
<div class="agents-layout">
|
||||
<section class="card agents-sidebar">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Agents</div>
|
||||
<div class="card-sub">${agents.length} configured.</div>
|
||||
</div>
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
agents.length > 1
|
||||
? html`
|
||||
<input
|
||||
class="field"
|
||||
type="text"
|
||||
placeholder="Filter agents…"
|
||||
.value=${props.sidebarFilter}
|
||||
@input=${(e: Event) =>
|
||||
props.onSidebarFilterChange((e.target as HTMLInputElement).value)}
|
||||
style="margin-top: 8px;"
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="agent-list" style="margin-top: 12px;">
|
||||
${
|
||||
filteredAgents.length === 0
|
||||
? html`
|
||||
<div class="muted">${sidebarFilter ? "No matching agents." : "No agents found."}</div>
|
||||
`
|
||||
: filteredAgents.map((agent) => {
|
||||
const badge = agentBadgeText(agent.id, defaultId);
|
||||
const 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}">
|
||||
<section class="agents-toolbar">
|
||||
<div class="agents-toolbar-row">
|
||||
<span class="agents-toolbar-label">Agent</span>
|
||||
<div class="agents-control-row">
|
||||
<div class="agents-control-select">
|
||||
<select
|
||||
class="agents-select"
|
||||
.value=${selectedId ?? ""}
|
||||
?disabled=${props.loading || agents.length === 0}
|
||||
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${
|
||||
agents.length === 0
|
||||
? html`
|
||||
<option value="">No agents</option>
|
||||
`
|
||||
: agents.map(
|
||||
(agent) => html`
|
||||
<option value=${agent.id} ?selected=${agent.id === selectedId}>
|
||||
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
|
||||
</option>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="agents-control-actions">
|
||||
${
|
||||
selectedAgent
|
||||
? html`
|
||||
<div class="agent-actions-wrap">
|
||||
<button
|
||||
class="agent-actions-toggle"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
actionsMenuOpen = !actionsMenuOpen;
|
||||
}}
|
||||
>⋯</button>
|
||||
${
|
||||
avatarUrl
|
||||
? html`<img src=${avatarUrl} alt="" class="agent-avatar__img" />`
|
||||
: html`<img src=${logoUrl} alt="" class="agent-avatar__img agent-avatar__logo" />`
|
||||
actionsMenuOpen
|
||||
? html`
|
||||
<div class="agent-actions-menu">
|
||||
<button type="button" @click=${() => {
|
||||
void navigator.clipboard.writeText(selectedAgent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}>Copy agent ID</button>
|
||||
<button
|
||||
type="button"
|
||||
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
|
||||
@click=${() => {
|
||||
props.onSetDefault(selectedAgent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
${defaultId && selectedAgent.id === defaultId ? "Already default" : "Set as default"}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="agent-info">
|
||||
<div class="agent-title">${normalizeAgentLabel(agent)}</div>
|
||||
<div class="agent-sub mono">${agent.id}</div>
|
||||
</div>
|
||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
</section>
|
||||
<section class="agents-main">
|
||||
${
|
||||
@@ -216,21 +207,18 @@ export function renderAgents(props: AgentsProps) {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${renderAgentHeader(
|
||||
selectedAgent,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
props.onSetDefault,
|
||||
props.basePath,
|
||||
)}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
|
||||
${
|
||||
props.activePanel === "overview"
|
||||
? renderAgentOverview({
|
||||
agent: selectedAgent,
|
||||
basePath: props.basePath,
|
||||
defaultId,
|
||||
configForm: props.config.form,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
agentIdentityError: props.agentIdentityError,
|
||||
agentIdentityLoading: props.agentIdentityLoading,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
@@ -347,79 +335,6 @@ export function renderAgents(props: AgentsProps) {
|
||||
|
||||
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(
|
||||
active: AgentsPanel,
|
||||
onSelect: (panel: AgentsPanel) => void,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { detectTextDirection } from "../text-direction.ts";
|
||||
import type { SessionsListResult } from "../types.ts";
|
||||
import type { ChatItem, MessageGroup } from "../types/chat-types.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
|
||||
import { agentLogoUrl } from "./agents-utils.ts";
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
|
||||
import "../components/resizable-divider.ts";
|
||||
|
||||
@@ -93,6 +94,7 @@ export type ChatProps = {
|
||||
onCloseSidebar?: () => void;
|
||||
onSplitRatioChange?: (ratio: number) => void;
|
||||
onChatScroll?: (event: Event) => void;
|
||||
basePath?: string;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
@@ -137,9 +139,6 @@ let slashMenuIndex = 0;
|
||||
let searchOpen = false;
|
||||
let searchQuery = "";
|
||||
let pinnedExpanded = false;
|
||||
let voiceActive = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let recognition: any = null;
|
||||
|
||||
function adjustTextareaHeight(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
@@ -361,52 +360,6 @@ function tokenEstimate(draft: string): string | null {
|
||||
return `~${Math.ceil(draft.length / 4)} tokens`;
|
||||
}
|
||||
|
||||
function startVoice(props: ChatProps, requestUpdate: () => void): void {
|
||||
const SR =
|
||||
(window as unknown as Record<string, unknown>).webkitSpeechRecognition ??
|
||||
(window as unknown as Record<string, unknown>).SpeechRecognition;
|
||||
if (!SR) {
|
||||
return;
|
||||
}
|
||||
const rec = new (SR as new () => Record<string, unknown>)();
|
||||
rec.continuous = false;
|
||||
rec.interimResults = true;
|
||||
rec.lang = "en-US";
|
||||
rec.onresult = (event: Record<string, unknown>) => {
|
||||
let transcript = "";
|
||||
const results = (
|
||||
event as { results: { length: number; [i: number]: { 0: { transcript: string } } } }
|
||||
).results;
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
transcript += results[i][0].transcript;
|
||||
}
|
||||
props.onDraftChange(transcript);
|
||||
};
|
||||
(rec as unknown as EventTarget).addEventListener("end", () => {
|
||||
voiceActive = false;
|
||||
recognition = null;
|
||||
requestUpdate();
|
||||
});
|
||||
(rec as unknown as EventTarget).addEventListener("error", () => {
|
||||
voiceActive = false;
|
||||
recognition = null;
|
||||
requestUpdate();
|
||||
});
|
||||
(rec as { start: () => void }).start();
|
||||
recognition = rec;
|
||||
voiceActive = true;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
function stopVoice(requestUpdate: () => void): void {
|
||||
if (recognition && typeof recognition.stop === "function") {
|
||||
recognition.stop();
|
||||
}
|
||||
recognition = null;
|
||||
voiceActive = false;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
function exportMarkdown(props: ChatProps): void {
|
||||
const history = Array.isArray(props.messages) ? props.messages : [];
|
||||
if (history.length === 0) {
|
||||
@@ -432,7 +385,7 @@ function exportMarkdown(props: ChatProps): void {
|
||||
function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = props.assistantAvatar ?? props.assistantAvatarUrl;
|
||||
const initials = name.slice(0, 2).toUpperCase();
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
|
||||
return html`
|
||||
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
|
||||
@@ -440,11 +393,11 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
${
|
||||
avatar
|
||||
? html`<img src=${avatar} alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />`
|
||||
: html`<div class="agent-chat__avatar">${initials}</div>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo"><img src=${logoUrl} alt="OpenClaw" /></div>`
|
||||
}
|
||||
<h2>${name}</h2>
|
||||
<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>
|
||||
<p class="agent-chat__hint">
|
||||
Type a message below · <kbd>/</kbd> for commands
|
||||
@@ -604,10 +557,6 @@ export function renderChat(props: ChatProps) {
|
||||
const hasAttachments = (props.attachments?.length ?? 0) > 0;
|
||||
const tokens = tokenEstimate(props.draft);
|
||||
|
||||
const hasVoice =
|
||||
typeof (window as unknown as Record<string, unknown>).webkitSpeechRecognition !== "undefined" ||
|
||||
typeof (window as unknown as Record<string, unknown>).SpeechRecognition !== "undefined";
|
||||
|
||||
const placeholder = props.connected
|
||||
? hasAttachments
|
||||
? "Add a message or paste more images..."
|
||||
@@ -663,7 +612,7 @@ export function renderChat(props: ChatProps) {
|
||||
`;
|
||||
}
|
||||
if (item.kind === "reading-indicator") {
|
||||
return renderReadingIndicatorGroup(assistantIdentity);
|
||||
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
|
||||
}
|
||||
if (item.kind === "stream") {
|
||||
return renderStreamingGroup(
|
||||
@@ -671,6 +620,7 @@ export function renderChat(props: ChatProps) {
|
||||
item.startedAt,
|
||||
props.onOpenSidebar,
|
||||
assistantIdentity,
|
||||
props.basePath,
|
||||
);
|
||||
}
|
||||
if (item.kind === "group") {
|
||||
@@ -682,6 +632,7 @@ export function renderChat(props: ChatProps) {
|
||||
showReasoning,
|
||||
assistantName: props.assistantName,
|
||||
assistantAvatar: assistantIdentity.avatar,
|
||||
basePath: props.basePath,
|
||||
onDelete: () => {
|
||||
deleted.delete(item.key);
|
||||
requestUpdate();
|
||||
@@ -808,8 +759,6 @@ export function renderChat(props: ChatProps) {
|
||||
${renderSearchBar(requestUpdate)}
|
||||
${renderPinnedSection(props, pinned, requestUpdate)}
|
||||
|
||||
${renderAgentBar(props)}
|
||||
|
||||
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
|
||||
<div
|
||||
class="chat-main"
|
||||
@@ -930,39 +879,13 @@ export function renderChat(props: ChatProps) {
|
||||
${icons.paperclip}
|
||||
</button>
|
||||
|
||||
${
|
||||
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
|
||||
}
|
||||
${nothing /* mic hidden for now */}
|
||||
|
||||
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="agent-chat__toolbar-right">
|
||||
<button class="btn-ghost" @click=${() => {
|
||||
searchOpen = !searchOpen;
|
||||
if (!searchOpen) {
|
||||
searchQuery = "";
|
||||
}
|
||||
requestUpdate();
|
||||
}} title="Search (Cmd+F)">
|
||||
${icons.search}
|
||||
</button>
|
||||
${nothing /* search hidden for now */}
|
||||
<button class="btn-ghost" @click=${() => exportMarkdown(props)} title="Export" ?disabled=${props.messages.length === 0}>
|
||||
${icons.download}
|
||||
</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;
|
||||
|
||||
function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
|
||||
@@ -120,49 +120,26 @@ export function renderDebug(props: DebugProps) {
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 18px;">
|
||||
<div class="row" style="justify-content: space-between; align-items: baseline;">
|
||||
<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>
|
||||
<div class="card-title">Event Log</div>
|
||||
<div class="card-sub">Latest gateway events.</div>
|
||||
${
|
||||
props.eventLog.length === 0
|
||||
? html`
|
||||
<div class="muted" style="margin-top: 12px">No events yet.</div>
|
||||
`
|
||||
: html`
|
||||
<div class="debug-event-log-scroll">
|
||||
<div class="list" style="margin-top: 12px;">
|
||||
${props.eventLog.map(
|
||||
(evt) => html`
|
||||
<details class="debug-event-entry">
|
||||
<summary class="debug-event-summary">
|
||||
<span class="debug-event-name">${evt.event}</span>
|
||||
<span class="debug-event-ts muted">${new Date(evt.ts).toLocaleTimeString()}</span>
|
||||
</summary>
|
||||
${
|
||||
evt.payload
|
||||
? html`<pre class="code-block debug-event-payload">${formatEventPayload(evt.payload)}</pre>`
|
||||
: html`
|
||||
<div class="muted" style="padding: 8px 0 4px">No payload.</div>
|
||||
`
|
||||
}
|
||||
</details>
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${evt.event}</div>
|
||||
<div class="list-sub">${new Date(evt.ts).toLocaleTimeString()}</div>
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<pre class="code-block">${formatEventPayload(evt.payload)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,16 +30,15 @@ export function renderLoginGate(state: AppViewState) {
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>${t("overview.access.token")}</span>
|
||||
<span>${t("overview.access.password")}</span>
|
||||
<input
|
||||
type="password"
|
||||
.value=${state.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
state.password = v;
|
||||
state.applySettings({ ...state.settings, token: v });
|
||||
}}
|
||||
placeholder="${t("login.tokenPlaceholder")}"
|
||||
placeholder="${t("login.passwordPlaceholder")}"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
state.connect();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { html, nothing, type TemplateResult } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { formatNextRun } from "../presenter.ts";
|
||||
import type {
|
||||
SessionsUsageResult,
|
||||
@@ -35,6 +34,25 @@ function blurDigits(value: string): TemplateResult {
|
||||
return html`${unsafeHTML(blurred)}`;
|
||||
}
|
||||
|
||||
type StatCard = {
|
||||
kind: string;
|
||||
tab: string;
|
||||
label: string;
|
||||
value: string | TemplateResult;
|
||||
hint: string | TemplateResult;
|
||||
redacted?: boolean;
|
||||
};
|
||||
|
||||
function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
|
||||
return html`
|
||||
<button class="ov-card" data-kind=${card.kind} @click=${() => onNavigate(card.tab)}>
|
||||
<span class="ov-card__label">${card.label}</span>
|
||||
<span class="ov-card__value ${card.redacted ? "redacted" : ""}">${card.value}</span>
|
||||
<span class="ov-card__hint">${card.hint}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderOverviewCards(props: OverviewCardsProps) {
|
||||
const totals = props.usageResult?.totals;
|
||||
const totalCost = formatCost(totals?.totalCost);
|
||||
@@ -52,75 +70,75 @@ export function renderOverviewCards(props: OverviewCardsProps) {
|
||||
const cronJobCount = props.cronJobs.length;
|
||||
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length;
|
||||
|
||||
const cronValue =
|
||||
cronEnabled == null
|
||||
? t("common.na")
|
||||
: cronEnabled
|
||||
? `${cronJobCount} jobs`
|
||||
: t("common.disabled");
|
||||
|
||||
const cronHint =
|
||||
failedCronCount > 0
|
||||
? html`<span class="danger">${failedCronCount} failed</span>`
|
||||
: cronNext
|
||||
? t("overview.stats.cronNext", { time: formatNextRun(cronNext) })
|
||||
: "";
|
||||
|
||||
const cards: StatCard[] = [
|
||||
{
|
||||
kind: "cost",
|
||||
tab: "usage",
|
||||
label: t("overview.cards.cost"),
|
||||
value: redact(totalCost, props.redacted),
|
||||
hint: redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted),
|
||||
redacted: props.redacted,
|
||||
},
|
||||
{
|
||||
kind: "sessions",
|
||||
tab: "sessions",
|
||||
label: t("overview.stats.sessions"),
|
||||
value: String(sessionCount ?? t("common.na")),
|
||||
hint: t("overview.stats.sessionsHint"),
|
||||
},
|
||||
{
|
||||
kind: "skills",
|
||||
tab: "skills",
|
||||
label: t("overview.cards.skills"),
|
||||
value: `${enabledSkills}/${totalSkills}`,
|
||||
hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`,
|
||||
},
|
||||
{
|
||||
kind: "cron",
|
||||
tab: "cron",
|
||||
label: t("overview.stats.cron"),
|
||||
value: cronValue,
|
||||
hint: cronHint,
|
||||
},
|
||||
];
|
||||
|
||||
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
|
||||
|
||||
return html`
|
||||
<section class="ov-cards">
|
||||
<div class="card ov-stat-card clickable" data-kind="cost" @click=${() => props.onNavigate("usage")}>
|
||||
<div class="ov-stat-card__inner">
|
||||
<div class="ov-stat-card__icon">${icons.barChart}</div>
|
||||
<div class="ov-stat-card__body">
|
||||
<div class="stat-label">${t("overview.cards.cost")}</div>
|
||||
<div class="stat-value ${props.redacted ? "redacted" : ""}">${redact(totalCost, props.redacted)}</div>
|
||||
<div class="muted">${redact(`${totalTokens} tokens · ${totalMessages} msgs`, props.redacted)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ov-stat-card clickable" data-kind="sessions" @click=${() => props.onNavigate("sessions")}>
|
||||
<div class="ov-stat-card__inner">
|
||||
<div class="ov-stat-card__icon">${icons.fileText}</div>
|
||||
<div class="ov-stat-card__body">
|
||||
<div class="stat-label">${t("overview.stats.sessions")}</div>
|
||||
<div class="stat-value">${sessionCount ?? t("common.na")}</div>
|
||||
<div class="muted">${t("overview.stats.sessionsHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ov-stat-card clickable" data-kind="skills" @click=${() => props.onNavigate("skills")}>
|
||||
<div class="ov-stat-card__inner">
|
||||
<div class="ov-stat-card__icon">${icons.zap}</div>
|
||||
<div class="ov-stat-card__body">
|
||||
<div class="stat-label">${t("overview.cards.skills")}</div>
|
||||
<div class="stat-value">${enabledSkills}/${totalSkills}</div>
|
||||
<div class="muted">${blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card ov-stat-card clickable" data-kind="cron" @click=${() => props.onNavigate("cron")}>
|
||||
<div class="ov-stat-card__inner">
|
||||
<div class="ov-stat-card__icon">${icons.scrollText}</div>
|
||||
<div class="ov-stat-card__body">
|
||||
<div class="stat-label">${t("overview.stats.cron")}</div>
|
||||
<div class="stat-value">
|
||||
${cronEnabled == null ? t("common.na") : cronEnabled ? `${cronJobCount} jobs` : t("common.disabled")}
|
||||
</div>
|
||||
<div class="muted">
|
||||
${
|
||||
failedCronCount > 0
|
||||
? html`<span class="danger">${failedCronCount} failed</span>`
|
||||
: nothing
|
||||
}
|
||||
${cronNext ? t("overview.stats.cronNext", { time: formatNextRun(cronNext) }) : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${cards.map((c) => renderStatCard(c, props.onNavigate))}
|
||||
</section>
|
||||
|
||||
${
|
||||
props.sessionsResult && props.sessionsResult.sessions.length > 0
|
||||
sessions.length > 0
|
||||
? html`
|
||||
<section class="card ov-recent-sessions">
|
||||
<div class="card-title">${t("overview.cards.recentSessions")}</div>
|
||||
<div class="ov-session-list">
|
||||
${props.sessionsResult.sessions.slice(0, 5).map(
|
||||
<section class="ov-recent">
|
||||
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
|
||||
<ul class="ov-recent__list">
|
||||
${sessions.map(
|
||||
(s) => html`
|
||||
<div class="ov-session-row ${props.redacted ? "redacted" : ""}">
|
||||
<span class="ov-session-key">${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)}</span>
|
||||
<span class="muted">${s.model ?? ""}</span>
|
||||
<span class="muted">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
|
||||
</div>
|
||||
<li class="ov-recent__row ${props.redacted ? "redacted" : ""}">
|
||||
<span class="ov-recent__key">${props.redacted ? redact(s.displayName || s.label || s.key, true) : blurDigits(s.displayName || s.label || s.key)}</span>
|
||||
<span class="ov-recent__model">${s.model ?? ""}</span>
|
||||
<span class="ov-recent__time">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
|
||||
</li>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -323,6 +323,8 @@ export function renderOverview(props: OverviewProps) {
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="ov-section-divider"></div>
|
||||
|
||||
${renderOverviewCards({
|
||||
usageResult: props.usageResult,
|
||||
sessionsResult: props.sessionsResult,
|
||||
@@ -336,6 +338,8 @@ export function renderOverview(props: OverviewProps) {
|
||||
|
||||
${renderOverviewAttention({ items: props.attentionItems })}
|
||||
|
||||
<div class="ov-section-divider"></div>
|
||||
|
||||
<div class="ov-bottom-grid" style="margin-top: 18px;">
|
||||
${renderOverviewEventLog({
|
||||
events: props.eventLog,
|
||||
|
||||
@@ -23,7 +23,18 @@ function buildProps(result: SessionsListResult): SessionsProps {
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
basePath: "",
|
||||
searchQuery: "",
|
||||
sortColumn: "updated",
|
||||
sortDir: "desc",
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
actionsOpenKey: null,
|
||||
onFiltersChange: () => undefined,
|
||||
onSearchChange: () => undefined,
|
||||
onSortChange: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onPageSizeChange: () => undefined,
|
||||
onActionsOpenChange: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onPatch: () => undefined,
|
||||
onDelete: () => undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { pathForTab } from "../navigation.ts";
|
||||
import { formatSessionTokens } from "../presenter.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||
@@ -13,12 +14,23 @@ export type SessionsProps = {
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
basePath: string;
|
||||
searchQuery: string;
|
||||
sortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
sortDir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actionsOpenKey: string | null;
|
||||
onFiltersChange: (next: {
|
||||
activeMinutes: string;
|
||||
limit: string;
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
}) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onActionsOpenChange: (key: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
onPatch: (
|
||||
key: string,
|
||||
@@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [
|
||||
{ value: "full", label: "full" },
|
||||
] as const;
|
||||
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||
|
||||
function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) {
|
||||
@@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
|
||||
return value;
|
||||
}
|
||||
|
||||
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
const key = (row.key ?? "").toLowerCase();
|
||||
const label = (row.label ?? "").toLowerCase();
|
||||
const kind = (row.kind ?? "").toLowerCase();
|
||||
const displayName = (row.displayName ?? "").toLowerCase();
|
||||
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(
|
||||
rows: GatewaySessionRow[],
|
||||
column: "key" | "kind" | "updated" | "tokens",
|
||||
dir: "asc" | "desc",
|
||||
): GatewaySessionRow[] {
|
||||
const cmp = dir === "asc" ? 1 : -1;
|
||||
return [...rows].toSorted((a, b) => {
|
||||
let diff = 0;
|
||||
switch (column) {
|
||||
case "key":
|
||||
diff = (a.key ?? "").localeCompare(b.key ?? "");
|
||||
break;
|
||||
case "kind":
|
||||
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
|
||||
break;
|
||||
case "updated": {
|
||||
const au = a.updatedAt ?? 0;
|
||||
const bu = b.updatedAt ?? 0;
|
||||
diff = au - bu;
|
||||
break;
|
||||
}
|
||||
case "tokens": {
|
||||
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
|
||||
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
|
||||
diff = at - bt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return diff * cmp;
|
||||
});
|
||||
}
|
||||
|
||||
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
|
||||
const start = page * pageSize;
|
||||
return rows.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
export function renderSessions(props: SessionsProps) {
|
||||
const 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`
|
||||
<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 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>
|
||||
<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">
|
||||
<span>Active within (minutes)</span>
|
||||
<div class="filters" style="margin-bottom: 12px;">
|
||||
<label class="field-inline">
|
||||
<span>Active</span>
|
||||
<input
|
||||
style="width: 72px;"
|
||||
placeholder="min"
|
||||
.value=${props.activeMinutes}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -135,9 +234,10 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<label class="field-inline">
|
||||
<span>Limit</span>
|
||||
<input
|
||||
style="width: 64px;"
|
||||
.value=${props.limit}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -148,8 +248,7 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include global</span>
|
||||
<label class="field-inline checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeGlobal}
|
||||
@@ -161,9 +260,9 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
<span>Global</span>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include unknown</span>
|
||||
<label class="field-inline checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeUnknown}
|
||||
@@ -175,39 +274,102 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
<span>Unknown</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="muted" style="margin-top: 12px;">
|
||||
${props.result ? `Store: ${props.result.path}` : ""}
|
||||
</div>
|
||||
|
||||
<div class="table" style="margin-top: 16px;">
|
||||
<div class="table-head">
|
||||
<div>Key</div>
|
||||
<div>Label</div>
|
||||
<div>Kind</div>
|
||||
<div>Updated</div>
|
||||
<div>Tokens</div>
|
||||
<div>Thinking</div>
|
||||
<div>Verbose</div>
|
||||
<div>Reasoning</div>
|
||||
<div>Actions</div>
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-toolbar">
|
||||
<div class="data-table-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by key, label, kind…"
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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`
|
||||
<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) =>
|
||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -219,6 +381,8 @@ function renderRow(
|
||||
basePath: string,
|
||||
onPatch: SessionsProps["onPatch"],
|
||||
onDelete: SessionsProps["onDelete"],
|
||||
onActionsOpenChange: (key: string | null) => void,
|
||||
actionsOpenKey: string | null,
|
||||
disabled: boolean,
|
||||
) {
|
||||
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||
@@ -234,36 +398,58 @@ function renderRow(
|
||||
typeof row.displayName === "string" && row.displayName.trim().length > 0
|
||||
? row.displayName.trim()
|
||||
: null;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label);
|
||||
const showDisplayName = Boolean(
|
||||
displayName &&
|
||||
displayName !== row.key &&
|
||||
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
|
||||
);
|
||||
const canLink = row.kind !== "global";
|
||||
const chatUrl = canLink
|
||||
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
||||
: null;
|
||||
const isMenuOpen = actionsOpenKey === row.key;
|
||||
const badgeClass =
|
||||
row.kind === "direct"
|
||||
? "data-table-badge--direct"
|
||||
: row.kind === "group"
|
||||
? "data-table-badge--group"
|
||||
: row.kind === "global"
|
||||
? "data-table-badge--global"
|
||||
: "data-table-badge--unknown";
|
||||
|
||||
return html`
|
||||
<div class="table-row">
|
||||
<div class="mono session-key-cell">
|
||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
||||
${showDisplayName ? html`<span class="muted session-key-display-name">${displayName}</span>` : nothing}
|
||||
</div>
|
||||
<div>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono session-key-cell">
|
||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
||||
${
|
||||
showDisplayName
|
||||
? html`<span class="muted session-key-display-name">${displayName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
.value=${row.label ?? ""}
|
||||
?disabled=${disabled}
|
||||
placeholder="(optional)"
|
||||
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>${row.kind}</div>
|
||||
<div>${updated}</div>
|
||||
<div>${formatSessionTokens(row)}</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
|
||||
</td>
|
||||
<td>${updated}</td>
|
||||
<td>${formatSessionTokens(row)}</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, {
|
||||
@@ -278,10 +464,11 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { verboseLevel: value || null });
|
||||
@@ -294,10 +481,11 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { reasoningLevel: value || null });
|
||||
@@ -310,12 +498,53 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="data-table-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="data-table-row-actions__trigger"
|
||||
aria-label="Open menu"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
onActionsOpenChange(isMenuOpen ? null : row.key);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
${
|
||||
isMenuOpen
|
||||
? html`
|
||||
<div class="data-table-row-actions__menu">
|
||||
${
|
||||
canLink
|
||||
? html`
|
||||
<a
|
||||
href=${chatUrl}
|
||||
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
|
||||
@click=${() => onActionsOpenChange(null)}
|
||||
>
|
||||
Open in Chat
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click=${() => {
|
||||
onActionsOpenChange(null);
|
||||
onDelete(row.key);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -37,19 +37,8 @@ export function renderSkills(props: SkillsProps) {
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Skills</div>
|
||||
<div class="card-sub">Bundled, managed, and workspace skills.</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field" style="flex: 1;">
|
||||
<span>Filter</span>
|
||||
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<label class="field" style="flex: 1; min-width: 180px;">
|
||||
<input
|
||||
.value=${props.filter}
|
||||
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
||||
@@ -57,6 +46,9 @@ export function renderSkills(props: SkillsProps) {
|
||||
/>
|
||||
</label>
|
||||
<div class="muted">${filtered.length} shown</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
||||
@@ -158,6 +158,7 @@ function renderDailyChartCompact(
|
||||
return html`
|
||||
<div class="daily-chart-compact">
|
||||
<div class="daily-chart-header">
|
||||
<div class="card-title" style="margin: 0;">Daily ${isTokenMode ? "Token" : "Cost"} Usage</div>
|
||||
<div class="chart-toggle small sessions-toggle">
|
||||
<button
|
||||
class="toggle-btn ${dailyChartMode === "total" ? "active" : ""}"
|
||||
@@ -166,13 +167,12 @@ function renderDailyChartCompact(
|
||||
Total
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}"
|
||||
class="toggle-btn ${dailyChartMode === "by-type" ? "active" : ""}
|
||||
@click=${() => onDailyChartModeChange("by-type")}
|
||||
>
|
||||
By Type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title">Daily ${isTokenMode ? "Token" : "Cost"} Usage</div>
|
||||
</div>
|
||||
<div class="daily-chart">
|
||||
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
export const usageStylesPart1 = `
|
||||
.usage-page-header {
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.usage-page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.usage-page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
/* ===== FILTERS & HEADER ===== */
|
||||
.usage-filters-inline {
|
||||
display: flex;
|
||||
|
||||
@@ -116,21 +116,21 @@ export const usageStylesPart2 = `
|
||||
.daily-chart-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ===== DAILY BAR CHART ===== */
|
||||
.daily-chart {
|
||||
margin-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.daily-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 200px;
|
||||
gap: 4px;
|
||||
padding: 8px 4px 36px;
|
||||
padding: 4px 4px 30px;
|
||||
}
|
||||
.daily-bar-wrapper {
|
||||
flex: 1;
|
||||
@@ -666,21 +666,21 @@ export const usageStylesPart2 = `
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ===== TWO COLUMN LAYOUT ===== */
|
||||
/* ===== TWO ROWS: Daily+Breakdown, Sessions (each scrollable) ===== */
|
||||
.usage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.usage-grid-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.usage-grid-left,
|
||||
.usage-grid-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ===== LEFT CARD (Daily + Breakdown) ===== */
|
||||
@@ -697,6 +697,6 @@ export const usageStylesPart2 = `
|
||||
.usage-left-card .sessions-panel-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2,14 +2,14 @@ export const usageStylesPart3 = `
|
||||
|
||||
/* ===== COMPACT DAILY CHART ===== */
|
||||
.daily-chart-compact {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.daily-chart-compact .sessions-panel-title {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.daily-chart-compact .daily-chart-bars {
|
||||
height: 100px;
|
||||
padding-bottom: 20px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
/* ===== COMPACT COST BREAKDOWN ===== */
|
||||
@@ -18,13 +18,17 @@ export const usageStylesPart3 = `
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.cost-breakdown-compact .cost-breakdown-header {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.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 {
|
||||
display: none;
|
||||
@@ -41,7 +45,7 @@ export const usageStylesPart3 = `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.sessions-card-title {
|
||||
font-weight: 600;
|
||||
@@ -55,8 +59,8 @@ export const usageStylesPart3 = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 8px 0 10px;
|
||||
gap: 10px;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -447,11 +447,6 @@ export function renderUsage(props: UsageProps) {
|
||||
return html`
|
||||
<style>${usageStylesString}</style>
|
||||
|
||||
<section class="usage-page-header">
|
||||
<div class="usage-page-title">Usage</div>
|
||||
<div class="usage-page-subtitle">See where tokens go, when sessions spike, and what drives cost.</div>
|
||||
</section>
|
||||
|
||||
<section class="card usage-header ${props.headerPinned ? "pinned" : ""}">
|
||||
<div class="usage-header-row">
|
||||
<div class="usage-header-title">
|
||||
|
||||
Reference in New Issue
Block a user