feat(ui): Control UI polish — skills revamp, markdown preview, agent workspace, macOS config tree (#53411) thanks @BunsDev

Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
Co-authored-by: Nova <nova@openknot.ai>
This commit is contained in:
Val Alexander 2026-03-24 01:21:13 -05:00
parent ecb3aa7fe0
commit a710366e9e
No known key found for this signature in database
40 changed files with 1534 additions and 662 deletions

View File

@ -8,6 +8,15 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
### Fixes
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.

View File

@ -73,7 +73,7 @@ extension ConfigSettings {
private var sidebar: some View {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
LazyVStack(alignment: .leading, spacing: 4) {
if self.sections.isEmpty {
Text("No config sections available.")
.font(.caption)
@ -82,7 +82,7 @@ extension ConfigSettings {
.padding(.vertical, 4)
} else {
ForEach(self.sections) { section in
self.sidebarRow(section)
self.sidebarSection(section)
}
}
}
@ -128,7 +128,6 @@ extension ConfigSettings {
}
self.actionRow
self.sectionHeader(section)
self.subsectionNav(section)
self.sectionForm(section)
if self.store.configDirty, !self.isNixMode {
Text("Unsaved changes")
@ -182,78 +181,76 @@ extension ConfigSettings {
.buttonStyle(.bordered)
}
private func sidebarRow(_ section: ConfigSection) -> some View {
let isSelected = self.activeSectionKey == section.key
return Button {
self.selectSection(section)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(section.label)
if let help = section.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
private func sidebarSection(_ section: ConfigSection) -> some View {
let isExpanded = self.activeSectionKey == section.key
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
return VStack(alignment: .leading, spacing: 2) {
Button {
self.selectSection(section)
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(section.label)
.lineLimit(1)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isExpanded && subsections.isEmpty
? Color.accentColor.opacity(0.18)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.padding(.vertical, 6)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.background(Color.clear)
.buttonStyle(.plain)
.contentShape(Rectangle())
if isExpanded, !subsections.isEmpty {
VStack(alignment: .leading, spacing: 1) {
self.sidebarSubRow(title: "All", key: nil, sectionKey: section.key)
ForEach(subsections) { sub in
self.sidebarSubRow(title: sub.label, key: sub.key, sectionKey: section.key)
}
}
.padding(.leading, 20)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.18), value: isExpanded)
}
private func sidebarSubRow(title: String, key: String?, sectionKey: String) -> some View {
let isSelected: Bool = {
guard self.activeSectionKey == sectionKey else { return false }
if let key { return self.activeSubsection == .key(key) }
return self.activeSubsection == .all
}()
return Button {
if let key {
self.activeSubsection = .key(key)
} else {
self.activeSubsection = .all
}
} label: {
Text(title)
.font(.callout)
.lineLimit(1)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity, alignment: .leading)
.buttonStyle(.plain)
.contentShape(Rectangle())
}
@ViewBuilder
private func subsectionNav(_ section: ConfigSection) -> some View {
let subsections = self.resolveSubsections(for: section)
if subsections.isEmpty {
EmptyView()
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
self.subsectionButton(
title: "All",
isSelected: self.activeSubsection == .all)
{
self.activeSubsection = .all
}
ForEach(subsections) { subsection in
self.subsectionButton(
title: subsection.label,
isSelected: self.activeSubsection == .key(subsection.key))
{
self.activeSubsection = .key(subsection.key)
}
}
}
.padding(.vertical, 2)
}
}
}
private func subsectionButton(
title: String,
isSelected: Bool,
action: @escaping () -> Void) -> some View
{
Button(action: action) {
Text(title)
.font(.callout.weight(.semibold))
.foregroundStyle(isSelected ? Color.accentColor : .primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private func sectionForm(_ section: ConfigSection) -> some View {
let subsection = self.activeSubsection
let defaultPath: ConfigPath = [.key(section.key)]

View File

@ -95,7 +95,8 @@ struct SkillsSettings: View {
skillKey: skill.skillKey,
skillName: skill.name,
envKey: envKey,
isPrimary: isPrimary)
isPrimary: isPrimary,
homepage: skill.homepage)
})
}
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
@ -258,8 +259,12 @@ private struct SkillRow: View {
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard !raw.isEmpty else { return nil }
return URL(string: raw)
guard !raw.isEmpty, let url = URL(string: raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
return url
}
private var enabledBinding: Binding<Bool> {
@ -428,6 +433,7 @@ private struct EnvEditorState: Identifiable {
let skillName: String
let envKey: String
let isPrimary: Bool
let homepage: String?
var id: String {
"\(self.skillKey)::\(self.envKey)"
@ -447,8 +453,15 @@ private struct EnvEditorView: View {
Text(self.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
if let homepageUrl = self.homepageUrl {
Link("Get your key →", destination: homepageUrl)
.font(.caption)
}
SecureField(self.editor.envKey, text: self.$value)
.textFieldStyle(.roundedBorder)
Text("Saved to openclaw.json under skills.entries.\(self.editor.skillKey)")
.font(.caption2)
.foregroundStyle(.tertiary)
HStack {
Button("Cancel") { self.dismiss() }
Spacer()
@ -464,6 +477,18 @@ private struct EnvEditorView: View {
.frame(width: 420)
}
private var homepageUrl: URL? {
guard let raw = self.editor.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
guard !raw.isEmpty, let url = URL(string: raw),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
return url
}
private var title: String {
self.editor.isPrimary ? "Set API Key" : "Set Environment Variable"
}
@ -539,12 +564,12 @@ final class SkillsSettingsModel {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
apiKey: value)
self.statusMessage = "Saved API key"
self.statusMessage = "Saved API key — stored in openclaw.json (skills.entries.\(skillKey))"
} else {
_ = try await GatewayConnection.shared.skillsUpdate(
skillKey: skillKey,
env: [envKey: value])
self.statusMessage = "Saved \(envKey)"
self.statusMessage = "Saved \(envKey) — stored in openclaw.json (skills.entries.\(skillKey).env)"
}
} catch {
self.statusMessage = error.localizedDescription
@ -608,7 +633,8 @@ extension SkillsSettings {
skillKey: "test",
skillName: "Test Skill",
envKey: "API_KEY",
isPrimary: true),
isPrimary: true,
homepage: "https://example.com"),
onSave: { _ in })
_ = editor.body
}

View File

@ -834,7 +834,7 @@
"engines": {
"node": ">=22.16.0"
},
"packageManager": "pnpm@10.23.0",
"packageManager": "pnpm@10.32.1",
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {

View File

@ -718,6 +718,9 @@ importers:
ui:
dependencies:
'@create-markdown/preview':
specifier: ^2.0.0
version: 2.0.0(@create-markdown/core@2.0.0)(shiki@3.23.0)
'@noble/ed25519':
specifier: 3.0.1
version: 3.0.1
@ -1066,6 +1069,25 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
'@create-markdown/core@2.0.0':
resolution: {integrity: sha512-xOmhoiDSa82EzjXp3aViQdB+xfCP4E2jEKxJiKJ702sup3p/CTCtL8fZBKQ3BvzASQRpq/xKCRXZZwRrg1DmZQ==}
engines: {node: '>=20.0.0'}
'@create-markdown/preview@2.0.0':
resolution: {integrity: sha512-3WTGCrCOVBy9wH2X82Oa2ZHJ+eiEqu8AlucckenWVtFSbzRKzkxgci0BRw7IDvsOsTUEMxS9Ltc9/hSUHkdidA==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@create-markdown/core': '>=2.0.0'
mermaid: '>=10.0.0'
shiki: '>=1.0.0'
peerDependenciesMeta:
'@create-markdown/core':
optional: true
mermaid:
optional: true
shiki:
optional: true
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
@ -5632,14 +5654,18 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
picomatch@2.3.2:
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pino-abstract-transport@2.0.0:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
@ -7529,6 +7555,14 @@ snapshots:
'@colors/colors@1.5.0':
optional: true
'@create-markdown/core@2.0.0':
optional: true
'@create-markdown/preview@2.0.0(@create-markdown/core@2.0.0)(shiki@3.23.0)':
optionalDependencies:
'@create-markdown/core': 2.0.0
shiki: 3.23.0
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
@ -11032,9 +11066,9 @@ snapshots:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.3):
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.3
picomatch: 4.0.4
fetch-blob@3.2.0:
dependencies:
@ -11978,7 +12012,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
picomatch: 2.3.2
mime-db@1.52.0: {}
@ -12433,10 +12467,12 @@ snapshots:
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@2.3.2: {}
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pino-abstract-transport@2.0.0:
dependencies:
split2: 4.2.0
@ -13284,8 +13320,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@2.1.0: {}
@ -13475,7 +13511,7 @@ snapshots:
vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.3
picomatch: 4.0.4
postcss: 8.5.8
rolldown: 1.0.0-rc.10
tinyglobby: 0.2.15
@ -13501,7 +13537,7 @@ snapshots:
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.2

View File

@ -3,7 +3,28 @@ name: coding-agent
description: 'Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Claude Code: use --print --permission-mode bypassPermissions (no PTY). Codex/Pi/OpenCode: pty:true required.'
metadata:
{
"openclaw": { "emoji": "🧩", "requires": { "anyBins": ["claude", "codex", "opencode", "pi"] } },
"openclaw":
{
"emoji": "🧩",
"requires": { "anyBins": ["claude", "codex", "opencode", "pi"] },
"install":
[
{
"id": "node-claude",
"kind": "node",
"package": "@anthropic-ai/claude-code",
"bins": ["claude"],
"label": "Install Claude Code CLI (npm)",
},
{
"id": "node-codex",
"kind": "node",
"package": "@openai/codex",
"bins": ["codex"],
"label": "Install Codex CLI (npm)",
},
],
},
}
---

View File

@ -3,7 +3,23 @@ name: gh-issues
description: "Fetch GitHub issues, spawn sub-agents to implement fixes and open PRs, then monitor and address PR review comments. Usage: /gh-issues [owner/repo] [--label bug] [--limit 5] [--milestone v1.0] [--assignee @me] [--fork user/repo] [--watch] [--interval 5] [--reviews-only] [--cron] [--dry-run] [--model glm-5] [--notify-channel -1002381931352]"
user-invocable: true
metadata:
{ "openclaw": { "requires": { "bins": ["curl", "git", "gh"] }, "primaryEnv": "GH_TOKEN" } }
{
"openclaw":
{
"requires": { "bins": ["curl", "git", "gh"] },
"primaryEnv": "GH_TOKEN",
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "gh",
"bins": ["gh"],
"label": "Install GitHub CLI (brew)",
},
],
},
}
---
# gh-issues — Auto-fix GitHub Issues with Parallel Sub-agents

View File

@ -9,6 +9,16 @@ metadata:
"emoji": "🌐",
"requires": { "bins": ["curl"], "env": ["OPENAI_API_KEY"] },
"primaryEnv": "OPENAI_API_KEY",
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "curl",
"bins": ["curl"],
"label": "Install curl (brew)",
},
],
},
}
---

View File

@ -1,7 +1,31 @@
---
name: session-logs
description: Search and analyze your own session logs (older/parent conversations) using jq.
metadata: { "openclaw": { "emoji": "📜", "requires": { "bins": ["jq", "rg"] } } }
metadata:
{
"openclaw":
{
"emoji": "📜",
"requires": { "bins": ["jq", "rg"] },
"install":
[
{
"id": "brew-jq",
"kind": "brew",
"formula": "jq",
"bins": ["jq"],
"label": "Install jq (brew)",
},
{
"id": "brew-rg",
"kind": "brew",
"formula": "ripgrep",
"bins": ["rg"],
"label": "Install ripgrep (brew)",
},
],
},
}
---
# session-logs

View File

@ -2,7 +2,24 @@
name: tmux
description: Remote-control tmux sessions for interactive CLIs by sending keystrokes and scraping pane output.
metadata:
{ "openclaw": { "emoji": "🧵", "os": ["darwin", "linux"], "requires": { "bins": ["tmux"] } } }
{
"openclaw":
{
"emoji": "🧵",
"os": ["darwin", "linux"],
"requires": { "bins": ["tmux"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "tmux",
"bins": ["tmux"],
"label": "Install tmux (brew)",
},
],
},
}
---
# tmux Session Control

View File

@ -5,7 +5,20 @@ homepage: https://developer.atlassian.com/cloud/trello/rest/
metadata:
{
"openclaw":
{ "emoji": "📋", "requires": { "bins": ["jq"], "env": ["TRELLO_API_KEY", "TRELLO_TOKEN"] } },
{
"emoji": "📋",
"requires": { "bins": ["jq"], "env": ["TRELLO_API_KEY", "TRELLO_TOKEN"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "jq",
"bins": ["jq"],
"label": "Install jq (brew)",
},
],
},
}
---

View File

@ -2,7 +2,24 @@
name: weather
description: "Get current weather and forecasts via wttr.in or Open-Meteo. Use when: user asks about weather, temperature, or forecasts for any location. NOT for: historical weather data, severe weather alerts, or detailed meteorological analysis. No API key needed."
homepage: https://wttr.in/:help
metadata: { "openclaw": { "emoji": "☔", "requires": { "bins": ["curl"] } } }
metadata:
{
"openclaw":
{
"emoji": "☔",
"requires": { "bins": ["curl"] },
"install":
[
{
"id": "brew",
"kind": "brew",
"formula": "curl",
"bins": ["curl"],
"label": "Install curl (brew)",
},
],
},
}
---
# Weather Skill

View File

@ -36,7 +36,7 @@ function formatSkillStatus(skill: SkillStatusEntry): string {
if (skill.blockedByAllowlist) {
return theme.warn("🚫 blocked");
}
return theme.error("✗ missing");
return theme.warn("△ needs setup");
}
function normalizeSkillEmoji(emoji?: string): string {
@ -192,7 +192,7 @@ export function formatSkillInfo(
? theme.warn("⏸ Disabled")
: skill.blockedByAllowlist
? theme.warn("🚫 Blocked by allowlist")
: theme.error("✗ Missing requirements");
: theme.warn("△ Needs setup");
lines.push(`${emoji} ${theme.heading(skill.name)} ${status}`);
lines.push("");
@ -265,6 +265,23 @@ export function formatSkillInfo(
}
}
if (skill.primaryEnv && skill.missing.env.includes(skill.primaryEnv)) {
lines.push("");
lines.push(theme.heading("API key setup:"));
if (skill.homepage) {
lines.push(` Get your key: ${skill.homepage}`);
}
lines.push(
` Save via UI: ${theme.muted("Control UI → Skills → ")}${skill.name}${theme.muted(" → Save key")}`,
);
lines.push(
` Save via CLI: ${formatCliCommand(`openclaw config set skills.entries.${skill.skillKey}.apiKey YOUR_KEY`)}`,
);
lines.push(
` Stored in: ${theme.muted("~/.openclaw/openclaw.json")} ${theme.muted(`(skills.entries.${skill.skillKey}.apiKey)`)}`,
);
}
return appendClawHubHint(lines.join("\n"), opts.json);
}

View File

@ -90,7 +90,7 @@ describe("skills-cli", () => {
]);
const output = formatSkillsList(report, { verbose: true });
expect(output).toContain("needs-stuff");
expect(output).toContain("missing");
expect(output).toContain("needs setup");
expect(output).toContain("anyBins");
expect(output).toContain("os:");
});

View File

@ -9,6 +9,7 @@
"test": "vitest run --config vitest.config.ts"
},
"dependencies": {
"@create-markdown/preview": "^2.0.0",
"@noble/ed25519": "3.0.1",
"dompurify": "^3.3.3",
"lit": "^3.3.2",

View File

@ -5,3 +5,13 @@
@import "./styles/chat.css";
@import "./styles/config.css";
@import "./styles/usage.css";
@import "@create-markdown/preview/themes/system.css";
.cm-preview {
--cm-mono: var(--mono);
--cm-link: var(--accent);
--cm-info: var(--info);
--cm-warning: var(--warn);
--cm-success: var(--ok);
--cm-danger: var(--danger);
}

View File

@ -68,7 +68,7 @@
/* Focus */
--focus: rgba(255, 92, 92, 0.2);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 80%, transparent);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
/* Grid */
@ -172,7 +172,7 @@
--info: #2563eb;
--focus: rgba(220, 38, 38, 0.15);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 70%, transparent);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 12px var(--accent-glow);
--grid-line: rgba(0, 0, 0, 0.04);
@ -236,7 +236,7 @@
--text-strong: #f5f5f7;
--chat-text: #e0e0e2;
--muted: #7a7a80;
--muted-strong: #5a5a62;
--muted-strong: #6e6e76;
--muted-foreground: #7a7a80;
/* Borders — whisper-thin, barely visible */
@ -371,7 +371,7 @@
--text-strong: #f0e4da;
--chat-text: #d8c8b8;
--muted: #9a8878;
--muted-strong: #7a6858;
--muted-strong: #8a7868;
--muted-foreground: #9a8878;
--border: #302418;
@ -467,7 +467,7 @@ body {
body {
margin: 0;
font: 400 13.5px/1.55 var(--font-body);
font: 400 14px/1.55 var(--font-body);
letter-spacing: -0.01em;
background: var(--bg);
color: var(--text);
@ -475,6 +475,12 @@ body {
-moz-osx-font-smoothing: grayscale;
}
@media (min-width: 1600px) {
body {
font-size: 15px;
}
}
/* Theme transition */
@keyframes theme-circle-transition {
0% {
@ -517,11 +523,12 @@ openclaw-app {
a {
color: var(--accent);
text-decoration: none;
text-decoration: underline;
text-underline-offset: 2px;
}
a:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
}
button,
@ -691,6 +698,17 @@ select {
animation-delay: 250ms;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Focus visible styles */
:focus-visible {
outline: none;

View File

@ -66,7 +66,9 @@
background: none;
border: none;
cursor: pointer;
padding: 2px;
padding: 4px;
min-width: 24px;
min-height: 24px;
border-radius: var(--radius-sm, 4px);
color: var(--muted);
opacity: 0;

View File

@ -227,8 +227,8 @@
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
@ -702,8 +702,8 @@
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);

View File

@ -79,9 +79,207 @@
.sidebar-markdown {
font-size: 14px;
line-height: 1.5;
line-height: 1.6;
color: var(--text);
}
/* ── Headings ── */
.sidebar-markdown :where(h1) {
font-size: 1.65em;
font-weight: 700;
letter-spacing: -0.025em;
margin: 1.6em 0 0.6em;
padding-bottom: 0.35em;
border-bottom: 1px solid var(--border);
line-height: 1.25;
}
.sidebar-markdown :where(h2) {
font-size: 1.35em;
font-weight: 650;
letter-spacing: -0.02em;
margin: 1.4em 0 0.5em;
padding-bottom: 0.25em;
border-bottom: 1px solid var(--border);
line-height: 1.3;
}
.sidebar-markdown :where(h3) {
font-size: 1.15em;
font-weight: 600;
letter-spacing: -0.01em;
margin: 1.2em 0 0.4em;
line-height: 1.35;
}
.sidebar-markdown :where(h4, h5, h6) {
font-size: 1em;
font-weight: 600;
margin: 1em 0 0.35em;
line-height: 1.4;
color: var(--text-strong);
}
.sidebar-markdown > :where(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 0;
}
/* ── Paragraphs & spacing ── */
.sidebar-markdown :where(p, ul, ol, pre, blockquote, table, details) {
margin: 0;
}
.sidebar-markdown :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table, p + details) {
margin-top: 0.75em;
}
.sidebar-markdown :where(h1 + p, h2 + p, h3 + p, h4 + p) {
margin-top: 0;
}
/* ── Lists ── */
.sidebar-markdown :where(ul, ol) {
padding-left: 1.6em;
}
.sidebar-markdown :where(li + li) {
margin-top: 0.3em;
}
.sidebar-markdown :where(li > p) {
margin-top: 0.25em;
}
.sidebar-markdown :where(li > ul, li > ol) {
margin-top: 0.25em;
}
/* ── Links ── */
.sidebar-markdown :where(a) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
transition: text-decoration-color var(--duration-fast) ease;
}
.sidebar-markdown :where(a:hover) {
text-decoration-color: var(--accent);
}
/* ── Inline code ── */
.sidebar-markdown code {
font-family: var(--mono);
font-size: 0.88em;
}
.sidebar-markdown :where(:not(pre) > code) {
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.12);
border: 1px solid var(--border);
}
:root[data-theme-mode="light"] .sidebar-markdown :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.06);
}
/* ── Code blocks ── */
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
overflow-x: auto;
margin-top: 0.75em;
}
:root[data-theme-mode="light"] .sidebar-markdown pre {
background: rgba(0, 0, 0, 0.04);
}
.sidebar-markdown :where(pre code) {
background: none;
border: none;
padding: 0;
font-size: 12.5px;
line-height: 1.55;
}
/* ── Blockquotes ── */
.sidebar-markdown :where(blockquote) {
border-left: 3px solid var(--border-strong);
padding: 8px 14px;
margin-left: 0;
margin-top: 0.75em;
color: var(--muted);
background: rgba(255, 255, 255, 0.02);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
:root[data-theme-mode="light"] .sidebar-markdown :where(blockquote) {
background: rgba(0, 0, 0, 0.025);
}
.sidebar-markdown :where(blockquote blockquote) {
margin-top: 8px;
}
/* ── Tables ── */
.sidebar-markdown :where(table) {
margin-top: 0.75em;
border-collapse: collapse;
width: 100%;
font-size: 13px;
display: block;
overflow-x: auto;
}
.sidebar-markdown :where(th, td) {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
vertical-align: top;
}
.sidebar-markdown :where(th) {
font-weight: 600;
font-size: 12px;
color: var(--muted);
background: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.sidebar-markdown :where(tbody tr:hover) {
background: var(--bg-hover);
}
/* ── Horizontal rules ── */
.sidebar-markdown :where(hr) {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
/* ── Bold / italic ── */
.sidebar-markdown :where(strong) {
font-weight: 600;
color: var(--text-strong);
}
/* ── Images ── */
.sidebar-markdown .markdown-inline-image {
display: block;
max-width: 100%;
@ -92,18 +290,34 @@
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 70%, transparent);
object-fit: contain;
margin-top: 0.75em;
}
.sidebar-markdown pre {
background: rgba(0, 0, 0, 0.12);
border-radius: var(--radius-sm);
padding: 12px;
overflow-x: auto;
/* ── Details / summary ── */
.sidebar-markdown :where(details) {
margin-top: 0.75em;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
overflow: hidden;
}
.sidebar-markdown code {
font-family: var(--mono);
.sidebar-markdown :where(summary) {
cursor: pointer;
padding: 8px 12px;
font-weight: 500;
font-size: 13px;
user-select: none;
transition: background var(--duration-fast) ease;
}
.sidebar-markdown :where(summary:hover) {
background: var(--bg-hover);
}
.sidebar-markdown :where(details[open] > :not(summary)) {
padding: 0 12px 10px;
}
/* Mobile: Full-screen modal */

View File

@ -165,7 +165,9 @@
align-items: center;
justify-content: center;
margin-left: 8px;
padding: 2px;
padding: 4px;
min-width: 24px;
min-height: 24px;
background: none;
border: none;
cursor: pointer;
@ -521,6 +523,69 @@
animation: none;
}
.statusDot.muted {
background: var(--muted);
box-shadow: none;
animation: none;
opacity: 0.5;
}
/* ===========================================
Skill Toggle Switch
=========================================== */
.skill-toggle-wrap {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.skill-toggle {
appearance: none;
width: 36px;
height: 20px;
border-radius: var(--radius-full);
background: var(--border);
position: relative;
cursor: pointer;
border: none;
outline: none;
transition:
background var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
flex-shrink: 0;
}
.skill-toggle::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform var(--duration-fast) var(--ease-out);
}
.skill-toggle:checked {
background: var(--accent);
}
.skill-toggle:checked::after {
transform: translateX(16px);
}
.skill-toggle:focus-visible {
box-shadow: var(--focus-ring);
}
.skill-toggle:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ===========================================
Buttons - Tactile with personality
=========================================== */
@ -906,9 +971,6 @@
.field textarea[aria-invalid="true"],
.field select[aria-invalid="true"] {
border-color: var(--danger);
box-shadow:
inset 0 1px 0 var(--card-highlight),
0 0 0 1px rgba(239, 68, 68, 0.2);
}
.cron-form-status {
@ -2928,23 +2990,6 @@ td.data-table-key-col {
min-width: 0;
}
.agents-toolbar-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.agents-control-row {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.agents-control-select {
flex: 1;
min-width: 0;
@ -2979,80 +3024,32 @@ td.data-table-key-col {
box-shadow: var(--focus-ring);
}
.agents-control-actions {
.agents-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.agents-refresh-btn {
white-space: nowrap;
}
.agent-actions-wrap {
position: relative;
}
.agent-actions-toggle {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--muted);
font-size: 14px;
cursor: pointer;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
}
.agent-actions-toggle:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.agent-actions-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 10;
min-width: 160px;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
box-shadow: var(--shadow-md);
display: grid;
gap: 1px;
}
.agent-actions-menu button {
display: block;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: var(--radius-sm);
.btn--ghost {
background: transparent;
color: var(--text);
font-size: 12px;
text-align: left;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.agent-actions-menu button:hover:not(:disabled) {
background: var(--bg-hover);
}
.agent-actions-menu button:disabled {
border-color: transparent;
color: var(--muted);
cursor: not-allowed;
}
.btn--ghost:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.btn--ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.agents-main {
@ -3197,6 +3194,19 @@ td.data-table-key-col {
opacity: 0.7;
}
.agent-tab--missing {
opacity: 0.5;
}
.agent-tab-badge {
margin-left: 4px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
color: var(--warn);
opacity: 0.8;
}
.agents-overview-grid {
display: grid;
gap: 12px;
@ -3273,15 +3283,16 @@ td.data-table-key-col {
.agent-chip-input:focus-within {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.agent-chip-input input {
.agent-chip-input input,
.agent-chip-input input:focus {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
outline: none;
box-shadow: none;
font-size: 13px;
padding: 0;
}
@ -3292,58 +3303,6 @@ td.data-table-key-col {
min-width: 200px;
}
.agent-files-grid {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
gap: 14px;
}
.agent-files-list {
display: grid;
gap: 8px;
}
.agent-file-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
width: 100%;
text-align: left;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
padding: 10px 12px;
cursor: pointer;
transition: border-color var(--duration-fast) ease;
}
.agent-file-row:hover {
border-color: var(--border-strong);
}
.agent-file-row.active {
border-color: var(--accent);
box-shadow: var(--focus-ring);
}
.agent-file-name {
font-weight: 600;
}
.agent-file-meta {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.agent-files-editor {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
background: var(--card);
}
.agent-file-field {
min-height: clamp(320px, 56vh, 720px);
}
@ -3365,10 +3324,6 @@ td.data-table-key-col {
flex-wrap: wrap;
}
.agent-file-title {
font-weight: 600;
}
.agent-file-sub {
color: var(--muted);
font-size: 12px;
@ -3716,10 +3671,6 @@ td.data-table-key-col {
justify-items: start;
}
.agent-files-grid {
grid-template-columns: 1fr;
}
.agent-tools-list {
grid-template-columns: 1fr;
}
@ -3736,8 +3687,9 @@ td.data-table-key-col {
max-width: none;
}
.agents-toolbar-label {
display: none;
.agents-toolbar-actions {
margin-left: 0;
justify-content: flex-end;
}
}
@ -4354,6 +4306,111 @@ details[open] > .ov-expandable-toggle::after {
transform: rotate(-90deg);
}
/* ===========================================
Markdown Preview Dialog
=========================================== */
.md-preview-dialog {
border: none;
background: transparent;
padding: 0;
max-width: none;
max-height: none;
width: 100vw;
height: 100vh;
overflow: hidden;
}
.md-preview-dialog::backdrop {
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
}
.md-preview-dialog__panel {
width: min(780px, calc(100vw - 48px));
max-height: calc(100vh - 64px);
margin: 32px auto;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
overflow: hidden;
animation: scale-in 0.2s var(--ease-out);
transition:
width 0.2s var(--ease-out),
max-height 0.2s var(--ease-out),
margin 0.2s var(--ease-out);
}
.md-preview-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: var(--card);
flex-shrink: 0;
}
.md-preview-dialog__title {
font-size: 13px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
}
.md-preview-dialog__actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.md-preview-dialog__body {
flex: 1;
overflow-y: auto;
padding: 16px 20px 24px;
}
.md-preview-dialog__panel.fullscreen {
width: calc(100vw - 32px);
max-width: none;
max-height: calc(100vh - 32px);
margin: 16px auto;
}
.md-preview-expand-btn .when-fullscreen {
display: none;
}
.md-preview-expand-btn.is-fullscreen .when-normal {
display: none;
}
.md-preview-expand-btn.is-fullscreen .when-fullscreen {
display: inline;
}
@media (max-width: 640px) {
.md-preview-dialog__panel {
width: calc(100vw - 16px);
max-height: calc(100vh - 32px);
margin: 16px auto;
border-radius: var(--radius-md);
}
.md-preview-dialog__header {
padding: 12px 16px;
}
.md-preview-dialog__body {
padding: 14px 12px 20px;
}
}
@media (max-width: 600px) {
.ov-cards {
grid-template-columns: repeat(2, 1fr);

View File

@ -103,8 +103,8 @@
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-full);
background: var(--bg-hover);
@ -869,6 +869,21 @@
border: 1px solid rgba(239, 68, 68, 0.3);
}
.cfg-field--error .cfg-input,
.cfg-field--error .cfg-textarea,
.cfg-field--error .cfg-select,
.cfg-field--error .cfg-number {
border-color: transparent;
box-shadow: none;
}
.cfg-field--error .cfg-input:focus,
.cfg-field--error .cfg-textarea:focus,
.cfg-field--error .cfg-select:focus {
border-color: var(--border);
box-shadow: none;
}
.cfg-field__label {
font-size: 12.5px;
font-weight: 600;

View File

@ -28,16 +28,17 @@
}
.usage-page-title {
font-size: 24px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: -0.025em;
color: var(--text-strong);
}
.usage-page-subtitle {
max-width: 720px;
color: var(--muted);
font-size: 14px;
font-size: 13px;
line-height: 1.5;
}
.usage-section-title {
@ -70,13 +71,13 @@
.usage-header {
display: grid;
gap: 14px;
gap: 16px;
}
.usage-header-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
}
@ -93,20 +94,21 @@
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
gap: 6px;
}
.usage-refresh-indicator,
.usage-loading-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
gap: 6px;
padding: 5px 10px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
background: color-mix(in srgb, var(--accent) 10%, var(--bg-muted));
color: var(--accent);
font-size: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
}
.usage-loading-badge {
@ -169,7 +171,7 @@
flex: 1 1 100%;
flex-wrap: wrap;
align-items: center;
gap: 10px;
gap: 8px;
}
.usage-presets,
@ -179,7 +181,35 @@
.usage-filter-actions {
display: inline-flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.chart-toggle {
padding: 3px;
background: var(--bg-muted);
border: 1px solid var(--border);
border-radius: var(--radius-md);
gap: 2px;
}
.chart-toggle .toggle-btn {
border: none;
border-radius: calc(var(--radius-md) - 3px);
background: transparent;
min-height: 28px;
padding: 4px 12px;
font-size: 12px;
}
.chart-toggle .toggle-btn:hover:not(.active) {
background: var(--bg-hover);
color: var(--text);
}
.chart-toggle .toggle-btn.active {
background: var(--accent-subtle);
border: none;
box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 12%, transparent);
}
.usage-date-range {
@ -195,12 +225,13 @@
.usage-query-input,
.usage-filters-inline select,
.usage-filters-inline input[type="text"] {
min-height: 36px;
min-height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
background: var(--bg-elevated);
color: var(--text);
font: inherit;
font-size: 12px;
transition:
border-color 0.18s var(--ease-out),
box-shadow 0.18s var(--ease-out),
@ -209,7 +240,12 @@
.usage-date-input,
.usage-select {
padding: 7px 10px;
padding: 6px 10px;
}
.usage-date-input:hover,
.usage-select:hover {
border-color: var(--border-strong);
}
.usage-date-input:focus,
@ -218,8 +254,8 @@
.usage-filters-inline select:focus,
.usage-filters-inline input[type="text"]:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 65%, var(--border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 12%, transparent);
}
.usage-separator,
@ -235,22 +271,41 @@
.session-log-meta,
.session-logs-header-count {
color: var(--muted);
font-size: 11px;
}
.usage-separator {
font-size: 12px;
}
.usage-query-hint {
font-size: 11px;
letter-spacing: 0.01em;
}
.usage-metric-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
border: 1px solid color-mix(in srgb, var(--accent) 12%, var(--border));
gap: 5px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 8%, var(--card));
font-size: 12px;
color: var(--text);
background: var(--bg-elevated);
font-size: 11px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.01em;
transition: border-color 0.18s var(--ease-out);
}
.usage-metric-badge:hover {
border-color: var(--border-strong);
}
.usage-metric-badge strong {
color: var(--text-strong);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* Toggle-btn active state layers on .btn for segmented controls */
@ -259,11 +314,25 @@
border-color: var(--accent);
background: var(--accent);
color: var(--primary-foreground);
box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 28%, transparent);
font-weight: 600;
box-shadow: 0 1px 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.usage-action-btn.usage-primary-btn:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 25%, transparent);
}
.chart-toggle .toggle-btn.active {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
background: var(--accent-subtle);
color: var(--accent);
font-weight: 600;
}
.usage-export-item:disabled {
opacity: 0.55;
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
}
@ -271,17 +340,25 @@
.usage-query-section {
display: grid;
gap: 10px;
padding-top: 2px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.usage-query-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
gap: 8px;
}
.usage-query-input {
width: 100%;
padding: 10px 12px;
padding: 8px 12px;
font-size: 12px;
}
.usage-query-input::placeholder {
color: var(--muted);
opacity: 0.7;
}
.usage-query-actions {
@ -293,7 +370,7 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: 6px;
}
details.usage-filter-select,
@ -314,39 +391,45 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-filter-select summary {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 8px 12px;
gap: 6px;
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
background: var(--bg-elevated);
color: var(--text);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition:
border-color 0.18s var(--ease-out),
box-shadow 0.18s var(--ease-out),
background 0.18s var(--ease-out);
}
.usage-filter-select[open] summary,
.usage-filter-select summary:hover {
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.usage-filter-select[open] summary {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent);
}
.usage-filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--accent-subtle);
color: var(--accent);
font-size: 11px;
background: var(--bg-muted);
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
}
.usage-filter-popover,
@ -383,26 +466,28 @@ details.usage-filter-select summary::-webkit-details-marker,
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 8px 10px;
padding: 6px 8px;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-muted) 72%, transparent);
background: transparent;
font-size: 12px;
transition: background 0.12s ease;
}
.usage-filter-option:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-muted));
background: var(--bg-hover);
}
.usage-export-item {
width: 100%;
min-height: 36px;
padding: 9px 10px;
min-height: 32px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
background: var(--bg-elevated);
color: var(--text);
text-align: left;
font: inherit;
font-size: 12px;
cursor: pointer;
transition:
border-color 0.18s var(--ease-out),
@ -411,8 +496,8 @@ details.usage-filter-select summary::-webkit-details-marker,
}
.usage-export-item:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 24%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--bg));
border-color: var(--border-strong);
background: var(--bg-hover);
color: var(--text-strong);
}
@ -431,13 +516,14 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-empty-state__feature {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
gap: 5px;
padding: 5px 10px;
border-radius: var(--radius-full);
border: 1px solid color-mix(in srgb, var(--accent) 16%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--card));
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
font-size: 12px;
font-size: 11px;
font-weight: 500;
}
.usage-query-chip button {
@ -453,15 +539,15 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-query-suggestion {
cursor: pointer;
transition:
transform 0.18s var(--ease-out),
border-color 0.18s var(--ease-out),
background 0.18s var(--ease-out);
background 0.18s var(--ease-out),
color 0.18s var(--ease-out);
}
.usage-query-suggestion:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 28%, var(--border));
background: color-mix(in srgb, var(--accent) 12%, var(--card));
border-color: var(--border-strong);
background: var(--bg-hover);
color: var(--text-strong);
}
.usage-callout {
@ -499,28 +585,27 @@ details.usage-filter-select summary::-webkit-details-marker,
}
.usage-empty-block {
padding: 20px 14px;
border: 1px dashed var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg-muted) 62%, transparent);
padding: 18px 14px;
border: 1px dashed color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
text-align: center;
color: var(--muted);
font-size: 13px;
font-size: 12px;
}
.usage-empty-block--compact {
padding: 12px;
padding: 10px;
border-style: solid;
}
.usage-overview-card {
display: grid;
gap: 16px;
gap: 14px;
}
.usage-overview-layout {
display: grid;
gap: 16px;
gap: 14px;
}
.usage-summary-grid,
@ -529,7 +614,7 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-mosaic-grid,
.context-breakdown-grid {
display: grid;
gap: 12px;
gap: 10px;
}
.usage-summary-grid {
@ -540,13 +625,13 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-summary-card,
.session-summary-card {
min-height: 118px;
gap: 8px;
min-height: 108px;
gap: 6px;
}
.usage-summary-card.stat,
.session-summary-card.stat {
padding: 16px;
padding: 14px 16px;
}
.usage-summary-card {
@ -572,9 +657,9 @@ details.usage-filter-select summary::-webkit-details-marker,
}
.usage-summary-card--hero {
min-height: 168px;
gap: 12px;
padding-block: 18px;
min-height: 148px;
gap: 10px;
padding-block: 16px;
}
.usage-summary-card--throughput {
@ -598,27 +683,28 @@ details.usage-filter-select summary::-webkit-details-marker,
.sessions-sort span,
.cost-breakdown-header,
.usage-mosaic-section-title {
font-size: 12px;
font-weight: 700;
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.06em;
}
.usage-summary-value,
.session-summary-value {
font-size: clamp(1.55rem, 2vw, 2rem);
line-height: 1.05;
font-size: clamp(1.4rem, 2vw, 1.85rem);
line-height: 1.1;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.usage-summary-card--hero .usage-summary-value {
font-size: clamp(2rem, 3vw, 2.8rem);
font-size: clamp(1.8rem, 2.5vw, 2.4rem);
}
.usage-summary-value--compact {
font-size: clamp(1.2rem, 1.75vw, 1.7rem);
font-size: clamp(1.15rem, 1.5vw, 1.5rem);
line-height: 1.15;
}
@ -639,39 +725,55 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-list-sub,
.usage-error-sub {
font-size: 12px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.usage-summary-hint {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
margin-left: 6px;
width: 16px;
height: 16px;
margin-left: 4px;
border-radius: var(--radius-full);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--bg-muted) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
color: var(--muted);
font-size: 10px;
font-size: 9px;
cursor: help;
opacity: 0.7;
transition: opacity 0.15s var(--ease-out);
}
.usage-summary-hint:hover {
opacity: 1;
}
.usage-insights-grid {
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
}
.usage-insights-grid--tight {
margin-top: 12px;
margin-top: 10px;
}
.usage-insight-card {
display: grid;
gap: 10px;
min-height: 168px;
gap: 12px;
align-content: start;
min-height: 148px;
padding: 16px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--card) 82%, var(--bg-muted));
background: var(--card);
transition:
border-color 0.18s var(--ease-out),
box-shadow 0.18s var(--ease-out);
}
.usage-insight-card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
}
.usage-insight-card--wide {
@ -682,7 +784,7 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-error-list,
.context-breakdown-list {
display: grid;
gap: 10px;
gap: 0;
}
.usage-list-item,
@ -690,8 +792,24 @@ details.usage-filter-select summary::-webkit-details-marker,
.context-breakdown-item {
display: flex;
justify-content: space-between;
gap: 10px;
gap: 12px;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
font-size: 13px;
}
.usage-list-item:last-child,
.usage-error-row:last-child,
.context-breakdown-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.usage-list-item:first-child,
.usage-error-row:first-child,
.context-breakdown-item:first-child {
padding-top: 0;
}
.usage-list-value {
@ -700,17 +818,20 @@ details.usage-filter-select summary::-webkit-details-marker,
justify-content: flex-end;
gap: 6px;
color: var(--text-strong);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.usage-error-rate {
font-size: 16px;
font-size: 15px;
font-weight: 700;
color: var(--danger);
font-variant-numeric: tabular-nums;
}
.usage-mosaic {
display: grid;
gap: 14px;
gap: 12px;
}
.usage-mosaic-header,
@ -725,7 +846,7 @@ details.usage-filter-select summary::-webkit-details-marker,
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 8px;
}
.usage-mosaic-title,
@ -736,13 +857,14 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-mosaic-title,
.session-detail-title {
font-size: 16px;
font-size: 15px;
font-weight: 700;
}
.usage-mosaic-total {
font-size: 14px;
font-weight: 700;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.usage-mosaic-grid {
@ -751,14 +873,14 @@ details.usage-filter-select summary::-webkit-details-marker,
.usage-mosaic-section {
display: grid;
gap: 10px;
gap: 8px;
}
.usage-mosaic-section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: 8px;
}
.usage-daypart-grid {
@ -1586,10 +1708,13 @@ details.usage-filter-select summary::-webkit-details-marker,
}
.usage-filter-popover,
.usage-export-popover {
width: min(320px, calc(100vw - 40px));
}
.usage-export-popover {
right: auto;
left: 0;
width: min(320px, calc(100vw - 40px));
}
.usage-daypart-grid {

View File

@ -966,6 +966,7 @@ export function renderApp(state: AppViewState) {
error: state.toolsCatalogError,
result: state.toolsCatalogResult,
},
modelCatalog: state.chatModelCatalog ?? [],
onRefresh: async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
@ -1278,16 +1279,21 @@ export function renderApp(state: AppViewState) {
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
statusFilter: state.skillsStatusFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
detailKey: state.skillsDetailKey,
onFilterChange: (next) => (state.skillsFilter = next),
onStatusFilterChange: (next) => (state.skillsStatusFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
onDetailOpen: (key) => (state.skillsDetailKey = key),
onDetailClose: () => (state.skillsDetailKey = null),
}),
)
: nothing

View File

@ -267,9 +267,11 @@ export type AppViewState = {
skillsReport: SkillStatusReport | null;
skillsError: string | null;
skillsFilter: string;
skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled";
skillEdits: Record<string, string>;
skillMessages: Record<string, SkillMessage>;
skillsBusyKey: string | null;
skillsDetailKey: string | null;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;

View File

@ -259,8 +259,7 @@ export class OpenClawApp extends LitElement {
@state() toolsCatalogLoading = false;
@state() toolsCatalogError: string | null = null;
@state() toolsCatalogResult: ToolsCatalogResult | null = null;
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" =
"overview";
@state() agentsPanel: "overview" | "files" | "tools" | "skills" | "channels" | "cron" = "files";
@state() agentFilesLoading = false;
@state() agentFilesError: string | null = null;
@state() agentFilesList: AgentsFilesListResult | null = null;
@ -398,9 +397,11 @@ export class OpenClawApp extends LitElement {
@state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null;
@state() skillsFilter = "";
@state() skillsStatusFilter: "all" | "ready" | "needs-setup" | "disabled" = "all";
@state() skillEdits: Record<string, string> = {};
@state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {};
@state() skillsDetailKey: string | null = null;
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;

View File

@ -279,6 +279,9 @@ describe("executeSlashCommand directives", () => {
if (method === "models.list") {
return { models: createModelCatalog(OPENAI_GPT5_MINI_MODEL) };
}
if (method === "models.list") {
return { models: [{ id: "gpt-5-mini", name: "gpt-5-mini", provider: "openai" }] };
}
throw new Error(`unexpected method: ${method}`);
});

View File

@ -108,7 +108,7 @@ export async function saveSkillApiKey(state: SkillsState, skillKey: string) {
await loadSkills(state);
setSkillMessage(state, skillKey, {
kind: "success",
message: "API key saved",
message: `API key saved — stored in openclaw.json (skills.entries.${skillKey})`,
});
} catch (err) {
const message = getErrorMessage(err);

View File

@ -448,6 +448,22 @@ export const icons = {
<path d="M10 10l-3 2 3 2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
`,
maximize: html`
<svg viewBox="0 0 24 24">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" x2="14" y1="3" y2="10" />
<line x1="3" x2="10" y1="21" y2="14" />
</svg>
`,
minimize: html`
<svg viewBox="0 0 24 24">
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" x2="21" y1="10" y2="3" />
<line x1="3" x2="10" y1="21" y2="14" />
</svg>
`,
} as const;
export type IconName = keyof typeof icons;

View File

@ -645,7 +645,7 @@ export type ModelCatalogEntry = {
provider: string;
contextWindow?: number;
reasoning?: boolean;
input?: Array<"text" | "image">;
input?: Array<"text" | "image" | "document">;
};
export type ToolCatalogProfile =

View File

@ -1,5 +1,10 @@
import { html, nothing } from "lit";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import type {
AgentIdentityResult,
AgentsFilesListResult,
AgentsListResult,
ModelCatalogEntry,
} from "../types.ts";
import {
buildModelOptions,
normalizeModelValue,
@ -23,6 +28,7 @@ export function renderAgentOverview(params: {
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
modelCatalog: ModelCatalogEntry[];
onConfigReload: () => void;
onConfigSave: () => void;
onModelChange: (agentId: string, modelId: string | null) => void;
@ -128,14 +134,16 @@ export function renderAgentOverview(params: {
>
${
isDefault
? nothing
? html`
<option value="">Not set</option>
`
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
${buildModelOptions(configForm, effectivePrimary ?? undefined, params.modelCatalog)}
</select>
</label>
<div class="field">

View File

@ -1,8 +1,10 @@
import { applyPreviewTheme } from "@create-markdown/preview";
import DOMPurify from "dompurify";
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { marked } from "marked";
import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import {
formatCronPayload,
formatCronSchedule,
@ -10,14 +12,13 @@ import {
formatNextRun,
} from "../presenter.ts";
import type {
AgentFileEntry,
AgentsFilesListResult,
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
CronJob,
CronStatus,
} from "../types.ts";
import { formatBytes, type AgentContext } from "./agents-utils.ts";
import { type AgentContext } from "./agents-utils.ts";
import type { AgentsPanel } from "./agents.ts";
import { resolveChannelExtras as resolveChannelExtrasFromConfig } from "./channel-config-extras.ts";
@ -387,7 +388,10 @@ export function renderAgentFiles(params: {
</div>
${
list
? html`<div class="muted mono" style="margin-top: 8px;">Workspace: ${list.workspace}</div>`
? html`<div class="muted mono" style="margin-top: 8px;">Workspace: <a
href="file://${list.workspace}"
class="workspace-link"
>${list.workspace}</a></div>`
: nothing
}
${
@ -402,96 +406,132 @@ export function renderAgentFiles(params: {
Load the agent workspace files to edit core instructions.
</div>
`
: html`
<div class="agent-files-grid" style="margin-top: 16px;">
<div class="agent-files-list">
${
files.length === 0
? html`
<div class="muted">No files found.</div>
`
: files.map((file) =>
renderAgentFileRow(file, active, () => params.onSelectFile(file.name)),
)
}
: files.length === 0
? html`
<div class="muted" style="margin-top: 16px">No files found.</div>
`
: html`
<div class="agent-tabs" style="margin-top: 14px;">
${files.map((file) => {
const isActive = active === file.name;
const label = file.name.replace(/\.md$/i, "");
return html`
<button
class="agent-tab ${isActive ? "active" : ""} ${file.missing ? "agent-tab--missing" : ""}"
@click=${() => params.onSelectFile(file.name)}
>${label}${
file.missing
? html`
<span class="agent-tab-badge">missing</span>
`
: nothing
}</button>
`;
})}
</div>
<div class="agent-files-editor">
${
!activeEntry
? html`
<div class="muted">Select a file to edit.</div>
`
: html`
<div class="agent-file-header">
<div>
<div class="agent-file-title mono">${activeEntry.name}</div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
title="Preview rendered markdown"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn
.closest(".agent-files-editor")
?.querySelector("dialog");
if (dialog) {
dialog.showModal();
}
}}
>
${icons.eye} Preview
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
Reset
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? "Saving…" : "Save"}
</button>
</div>
${
!activeEntry
? html`
<div class="muted" style="margin-top: 16px">Select a file to edit.</div>
`
: html`
<div class="agent-file-header" style="margin-top: 14px;">
<div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
${
activeEntry.missing
? html`
<div class="callout info" style="margin-top: 10px">
This file is missing. Saving will create it in the agent workspace.
</div>
`
: nothing
}
<label class="field agent-file-field" style="margin-top: 12px;">
<span>Content</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
title="Preview rendered markdown"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn.closest(".card")?.querySelector("dialog");
if (dialog) {
dialog.showModal();
}
}}
>
${icons.eye} Preview
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
Reset
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${
activeEntry.missing
? html`
<div class="callout info" style="margin-top: 10px">
This file is missing. Saving will create it in the agent workspace.
</div>
`
: nothing
}
<label class="field agent-file-field" style="margin-top: 12px;">
<span>Content</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${(e: Event) => {
const dialog = e.currentTarget as HTMLElement;
dialog
.querySelector(".md-preview-dialog__panel")
?.classList.remove("fullscreen");
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
<div class="md-preview-dialog__actions">
<button
class="btn btn--sm md-preview-expand-btn"
title="Toggle fullscreen"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const panel = btn.closest(".md-preview-dialog__panel");
if (!panel) {
return;
}
const isFullscreen = panel.classList.toggle("fullscreen");
btn.classList.toggle("is-fullscreen", isFullscreen);
}}
><span class="when-normal">${icons.maximize} Expand</span><span class="when-fullscreen">${icons.minimize} Collapse</span></button>
<button
class="btn btn--sm"
title="Edit file"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea");
textarea?.focus();
}}
>${icons.edit} Editor</button>
<button
class="btn btn--sm"
@click=${(e: Event) => {
@ -499,42 +539,16 @@ export function renderAgentFiles(params: {
}}
>${icons.x} Close</button>
</div>
<div class="md-preview-dialog__body sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(draft))}
</div>
</div>
</dialog>
`
}
</div>
</div>
`
<div class="md-preview-dialog__body">
${unsafeHTML(applyPreviewTheme(marked.parse(draft, { gfm: true, breaks: true }) as string, { sanitize: (h: string) => DOMPurify.sanitize(h) }))}
</div>
</div>
</dialog>
`
}
`
}
</section>
`;
}
function renderAgentFileRow(file: AgentFileEntry, active: string | null, onSelect: () => void) {
const status = file.missing
? "Missing"
: `${formatBytes(file.size)} · ${formatRelativeTimestamp(file.updatedAtMs ?? null)}`;
return html`
<button
type="button"
class="agent-file-row ${active === file.name ? "active" : ""}"
@click=${onSelect}
>
<div>
<div class="agent-file-name mono">${file.name}</div>
<div class="agent-file-meta">${status}</div>
</div>
${
file.missing
? html`
<span class="agent-pill warn">missing</span>
`
: nothing
}
</button>
`;
}

View File

@ -1,4 +1,4 @@
import { html } from "lit";
import { html, nothing } from "lit";
import {
expandToolGroups,
normalizeToolName,
@ -8,6 +8,7 @@ import type {
AgentIdentityResult,
AgentsFilesListResult,
AgentsListResult,
ModelCatalogEntry,
ToolCatalogProfile,
ToolsCatalogResult,
} from "../types.ts";
@ -574,16 +575,38 @@ function resolveConfiguredModels(
export function buildModelOptions(
configForm: Record<string, unknown> | null,
current?: string | null,
catalog?: ModelCatalogEntry[],
) {
const options = resolveConfiguredModels(configForm);
const hasCurrent = current ? options.some((option) => option.value === current) : false;
if (current && !hasCurrent) {
const seen = new Set<string>();
const options: ConfiguredModelOption[] = [];
const addOption = (value: string, label: string) => {
const key = value.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
options.push({ value, label });
};
for (const opt of resolveConfiguredModels(configForm)) {
addOption(opt.value, opt.label);
}
if (catalog) {
for (const entry of catalog) {
const provider = entry.provider?.trim();
const value = provider ? `${provider}/${entry.id}` : entry.id;
const label = provider ? `${entry.id} · ${provider}` : entry.id;
addOption(value, label);
}
}
if (current && !seen.has(current.toLowerCase())) {
options.unshift({ value: current, label: `Current (${current})` });
}
if (options.length === 0) {
return html`
<option value="" disabled>No configured models</option>
`;
return nothing;
}
return options.map((option) => html`<option value=${option.value}>${option.label}</option>`);
}

View File

@ -86,6 +86,7 @@ function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
error: null,
result: null,
},
modelCatalog: [],
onRefresh: () => undefined,
onSelectAgent: () => undefined,
onSelectPanel: () => undefined,

View File

@ -6,6 +6,7 @@ import type {
ChannelsStatusSnapshot,
CronJob,
CronStatus,
ModelCatalogEntry,
SkillStatusReport,
ToolsCatalogResult,
} from "../types.ts";
@ -81,6 +82,7 @@ export type AgentsProps = {
agentIdentityById: Record<string, AgentIdentityResult>;
agentSkills: AgentSkillsState;
toolsCatalog: ToolsCatalogState;
modelCatalog: ModelCatalogEntry[];
onRefresh: () => void;
onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void;
@ -135,72 +137,51 @@ export function renderAgents(props: AgentsProps) {
<div class="agents-layout">
<section class="agents-toolbar">
<div class="agents-toolbar-row">
<span class="agents-toolbar-label">Agent</span>
<div class="agents-control-row">
<div class="agents-control-select">
<select
class="agents-select"
.value=${selectedId ?? ""}
?disabled=${props.loading || agents.length === 0}
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
>
${
agents.length === 0
? html`
<option value="">No agents</option>
`
: agents.map(
(agent) => html`
<div class="agents-control-select">
<select
class="agents-select"
.value=${selectedId ?? ""}
?disabled=${props.loading || agents.length === 0}
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
>
${
agents.length === 0
? html`
<option value="">No agents</option>
`
: agents.map(
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selectedId}>
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
</option>
`,
)
}
</select>
</div>
<div class="agents-control-actions">
${
selectedAgent
? html`
<div class="agent-actions-wrap">
<button
class="agent-actions-toggle"
type="button"
@click=${() => {
actionsMenuOpen = !actionsMenuOpen;
}}
></button>
${
actionsMenuOpen
? html`
<div class="agent-actions-menu">
<button type="button" @click=${() => {
void navigator.clipboard.writeText(selectedAgent.id);
actionsMenuOpen = false;
}}>Copy agent ID</button>
<button
type="button"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => {
props.onSetDefault(selectedAgent.id);
actionsMenuOpen = false;
}}
>
${defaultId && selectedAgent.id === defaultId ? "Already default" : "Set as default"}
</button>
</div>
`
: nothing
}
</div>
`
: nothing
)
}
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</select>
</div>
<div class="agents-toolbar-actions">
${
selectedAgent
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
@click=${() => void navigator.clipboard.writeText(selectedAgent.id)}
title="Copy agent ID to clipboard"
>Copy ID</button>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => props.onSetDefault(selectedAgent.id)}
title=${defaultId && selectedAgent.id === defaultId ? "Already the default agent" : "Set as the default agent"}
>${defaultId && selectedAgent.id === defaultId ? "Default" : "Set Default"}</button>
`
: nothing
}
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div>
${
@ -234,6 +215,7 @@ export function renderAgents(props: AgentsProps) {
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
@ -350,8 +332,6 @@ export function renderAgents(props: AgentsProps) {
`;
}
let actionsMenuOpen = false;
function renderAgentTabs(
active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void,

View File

@ -656,13 +656,14 @@ function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof not
<input
type="text"
placeholder="Search messages..."
aria-label="Search messages"
.value=${vs.searchQuery}
@input=${(e: Event) => {
vs.searchQuery = (e.target as HTMLInputElement).value;
requestUpdate();
}}
/>
<button class="btn btn--ghost" @click=${() => {
<button class="btn btn--ghost" aria-label="Close search" @click=${() => {
vs.searchOpen = false;
vs.searchQuery = "";
requestUpdate();
@ -739,13 +740,15 @@ function renderSlashMenu(
// Arg-picker mode: show options for the selected command
if (vs.slashMenuMode === "args" && vs.slashMenuCommand && vs.slashMenuArgItems.length > 0) {
return html`
<div class="slash-menu">
<div class="slash-menu" role="listbox" aria-label="Command arguments">
<div class="slash-menu-group">
<div class="slash-menu-group__label">/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}</div>
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
class="slash-menu-item ${i === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
role="option"
aria-selected=${i === vs.slashMenuIndex}
@click=${() => selectSlashArg(arg, props, requestUpdate, true)}
@mouseenter=${() => {
vs.slashMenuIndex = i;
@ -798,6 +801,8 @@ function renderSlashMenu(
({ cmd, globalIdx }) => html`
<div
class="slash-menu-item ${globalIdx === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
role="option"
aria-selected=${globalIdx === vs.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
@mouseenter=${() => {
vs.slashMenuIndex = globalIdx;
@ -825,7 +830,7 @@ function renderSlashMenu(
}
return html`
<div class="slash-menu">
<div class="slash-menu" role="listbox" aria-label="Slash commands">
${sections}
<div class="slash-menu-footer">
<kbd></kbd> navigate
@ -1254,6 +1259,7 @@ export function renderChat(props: ChatProps) {
document.querySelector<HTMLInputElement>(".agent-chat__file-input")?.click();
}}
title="Attach file"
aria-label="Attach file"
?disabled=${!props.connected}
>
${icons.paperclip}
@ -1332,14 +1338,14 @@ export function renderChat(props: ChatProps) {
</button>
`
}
<button class="btn btn--ghost" @click=${() => exportMarkdown(props)} title="Export" ?disabled=${props.messages.length === 0}>
<button class="btn btn--ghost" @click=${() => exportMarkdown(props)} title="Export" aria-label="Export chat" ?disabled=${props.messages.length === 0}>
${icons.download}
</button>
${
canAbort && (isBusy || props.sending)
? html`
<button class="chat-send-btn chat-send-btn--stop" @click=${props.onAbort} title="Stop">
<button class="chat-send-btn chat-send-btn--stop" @click=${props.onAbort} title="Stop" aria-label="Stop generating">
${icons.stop}
</button>
`
@ -1354,6 +1360,7 @@ export function renderChat(props: ChatProps) {
}}
?disabled=${!props.connected || props.sending}
title=${isBusy ? "Queue" : "Send"}
aria-label=${isBusy ? "Queue message" : "Send message"}
>
${icons.send}
</button>

View File

@ -887,6 +887,7 @@ export function renderConfig(props: ConfigProps) {
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
@ -896,6 +897,7 @@ export function renderConfig(props: ConfigProps) {
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
>
×

View File

@ -9,33 +9,95 @@ import {
renderSkillStatusChips,
} from "./skills-shared.ts";
export type SkillsStatusFilter = "all" | "ready" | "needs-setup" | "disabled";
export type SkillsProps = {
connected: boolean;
loading: boolean;
report: SkillStatusReport | null;
error: string | null;
filter: string;
statusFilter: SkillsStatusFilter;
edits: Record<string, string>;
busyKey: string | null;
messages: SkillMessageMap;
detailKey: string | null;
onFilterChange: (next: string) => void;
onStatusFilterChange: (next: SkillsStatusFilter) => void;
onRefresh: () => void;
onToggle: (skillKey: string, enabled: boolean) => void;
onEdit: (skillKey: string, value: string) => void;
onSaveKey: (skillKey: string) => void;
onInstall: (skillKey: string, name: string, installId: string) => void;
onDetailOpen: (skillKey: string) => void;
onDetailClose: () => void;
};
type StatusTabDef = { id: SkillsStatusFilter; label: string };
const STATUS_TABS: StatusTabDef[] = [
{ id: "all", label: "All" },
{ id: "ready", label: "Ready" },
{ id: "needs-setup", label: "Needs Setup" },
{ id: "disabled", label: "Disabled" },
];
function skillMatchesStatus(skill: SkillStatusEntry, status: SkillsStatusFilter): boolean {
switch (status) {
case "all":
return true;
case "ready":
return !skill.disabled && skill.eligible;
case "needs-setup":
return !skill.disabled && !skill.eligible;
case "disabled":
return skill.disabled;
}
}
function skillStatusClass(skill: SkillStatusEntry): string {
if (skill.disabled) {
return "muted";
}
return skill.eligible ? "ok" : "warn";
}
export function renderSkills(props: SkillsProps) {
const skills = props.report?.skills ?? [];
const statusCounts: Record<SkillsStatusFilter, number> = {
all: skills.length,
ready: 0,
"needs-setup": 0,
disabled: 0,
};
for (const s of skills) {
if (s.disabled) {
statusCounts.disabled++;
} else if (s.eligible) {
statusCounts.ready++;
} else {
statusCounts["needs-setup"]++;
}
}
const afterStatus =
props.statusFilter === "all"
? skills
: skills.filter((s) => skillMatchesStatus(s, props.statusFilter));
const filter = props.filter.trim().toLowerCase();
const filtered = filter
? skills.filter((skill) =>
? afterStatus.filter((skill) =>
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: skills;
: afterStatus;
const groups = groupSkills(filtered);
const detailSkill = props.detailKey
? (skills.find((s) => s.skillKey === props.detailKey) ?? null)
: null;
return html`
<section class="card">
<div class="row" style="justify-content: space-between;">
@ -44,13 +106,26 @@ export function renderSkills(props: SkillsProps) {
<div class="card-sub">Installed skills and their status.</div>
</div>
<button class="btn" ?disabled=${props.loading || !props.connected} @click=${props.onRefresh}>
${props.loading ? "Loading" : "Refresh"}
${props.loading ? "Loading\u2026" : "Refresh"}
</button>
</div>
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
<div class="agent-tabs" style="margin-top: 14px;">
${STATUS_TABS.map(
(tab) => html`
<button
class="agent-tab ${props.statusFilter === tab.id ? "active" : ""}"
@click=${() => props.onStatusFilterChange(tab.id)}
>
${tab.label}<span class="agent-tab-count">${statusCounts[tab.id]}</span>
</button>
`,
)}
</div>
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
<a
class="btn"
class="btn btn--sm"
href="https://clawhub.com"
target="_blank"
rel="noreferrer"
@ -88,9 +163,8 @@ export function renderSkills(props: SkillsProps) {
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) => {
const collapsedByDefault = group.id === "workspace" || group.id === "built-in";
return html`
<details class="agent-skills-group" ?open=${!collapsedByDefault}>
<details class="agent-skills-group" open>
<summary class="agent-skills-header">
<span>${group.label}</span>
<span class="muted">${group.skills.length}</span>
@ -105,10 +179,50 @@ export function renderSkills(props: SkillsProps) {
`
}
</section>
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing}
`;
}
function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey;
const dotClass = skillStatusClass(skill);
return html`
<div
class="list-item list-item-clickable"
@click=${() => props.onDetailOpen(skill.skillKey)}
>
<div class="list-main">
<div class="list-title" style="display: flex; align-items: center; gap: 8px;">
<span class="statusDot ${dotClass}"></span>
${skill.emoji ? html`<span>${skill.emoji}</span>` : nothing}
<span>${skill.name}</span>
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
</div>
<div class="list-meta" style="display: flex; align-items: center; justify-content: flex-end; gap: 10px;">
<label
class="skill-toggle-wrap"
@click=${(e: Event) => e.stopPropagation()}
>
<input
type="checkbox"
class="skill-toggle"
.checked=${!skill.disabled}
?disabled=${busy}
@change=${(e: Event) => {
e.stopPropagation();
props.onToggle(skill.skillKey, skill.disabled);
}}
/>
</label>
</div>
</div>
`;
}
function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey;
const apiKey = props.edits[skill.skillKey] ?? "";
const message = props.messages[skill.skillKey] ?? null;
@ -116,92 +230,124 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const showBundledBadge = Boolean(skill.bundled && skill.source !== "openclaw-bundled");
const missing = computeSkillMissing(skill);
const reasons = computeSkillReasons(skill);
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
<dialog class="md-preview-dialog" open @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains("md-preview-dialog")) {
props.onDetailClose();
}
}}>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title" style="display: flex; align-items: center; gap: 8px;">
<span class="statusDot ${skillStatusClass(skill)}"></span>
${skill.emoji ? html`<span style="font-size: 18px;">${skill.emoji}</span>` : nothing}
<span>${skill.name}</span>
</div>
<button class="btn btn--sm" @click=${props.onDetailClose}>Close</button>
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
${renderSkillStatusChips({ skill, showBundledBadge })}
${
missing.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")}
</div>
`
: nothing
}
${
reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
</div>
`
: nothing
}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
<button
class="btn"
?disabled=${busy}
@click=${() => props.onToggle(skill.skillKey, skill.disabled)}
>
${skill.disabled ? "Enable" : "Disable"}
</button>
<div class="md-preview-dialog__body" style="display: grid; gap: 16px;">
<div>
<div style="font-size: 14px; line-height: 1.5; color: var(--text);">${skill.description}</div>
${renderSkillStatusChips({ skill, showBundledBadge })}
</div>
${
canInstall
? html`<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing…" : skill.install[0].label}
</button>`
missing.length > 0
? html`
<div class="callout" style="border-color: var(--warn-subtle); background: var(--warn-subtle); color: var(--warn);">
<div style="font-weight: 600; margin-bottom: 4px;">Missing requirements</div>
<div>${missing.join(", ")}</div>
</div>
`
: nothing
}
</div>
${
message
? html`<div
class="muted"
style="margin-top: 8px; color: ${
message.kind === "error"
? "var(--danger-color, #d14343)"
: "var(--success-color, #0a7f5a)"
};"
>
${message.message}
</div>`
: nothing
}
${
skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<input
type="password"
.value=${apiKey}
@input=${(e: Event) =>
props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)}
/>
</div>
<button
class="btn primary"
style="margin-top: 8px;"
${
reasons.length > 0
? html`
<div class="muted" style="font-size: 13px;">
Reason: ${reasons.join(", ")}
</div>
`
: nothing
}
<div style="display: flex; align-items: center; gap: 12px;">
<label class="skill-toggle-wrap">
<input
type="checkbox"
class="skill-toggle"
.checked=${!skill.disabled}
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
@change=${() => props.onToggle(skill.skillKey, skill.disabled)}
/>
</label>
<span style="font-size: 13px; font-weight: 500;">
${skill.disabled ? "Disabled" : "Enabled"}
</span>
${
canInstall
? html`<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing\u2026" : skill.install[0].label}
</button>`
: nothing
}
</div>
${
message
? html`<div
class="callout ${message.kind === "error" ? "danger" : "success"}"
>
Save key
</button>
`
: nothing
}
${message.message}
</div>`
: nothing
}
${
skill.primaryEnv
? html`
<div style="display: grid; gap: 8px;">
<div class="field">
<span>API key <span class="muted" style="font-weight: normal; font-size: 0.88em;">(${skill.primaryEnv})</span></span>
<input
type="password"
.value=${apiKey}
@input=${(e: Event) =>
props.onEdit(skill.skillKey, (e.target as HTMLInputElement).value)}
/>
</div>
${
skill.homepage
? html`<div class="muted" style="font-size: 13px;">
Get your key: <a href="${skill.homepage}" target="_blank" rel="noopener">${skill.homepage}</a>
</div>`
: nothing
}
<button
class="btn primary"
?disabled=${busy}
@click=${() => props.onSaveKey(skill.skillKey)}
>
Save key
</button>
</div>
`
: nothing
}
<div style="border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 6px; font-size: 12px; color: var(--muted);">
<div><span style="font-weight: 600;">Source:</span> ${skill.source}</div>
<div style="font-family: var(--mono); word-break: break-all;">${skill.filePath}</div>
${skill.homepage ? html`<div><a href="${skill.homepage}" target="_blank" rel="noopener">${skill.homepage}</a></div>` : nothing}
</div>
</div>
</div>
</div>
</dialog>
`;
}

View File

@ -178,6 +178,22 @@ function renderDailyChartCompact(
const values = daily.map((d) => (isTokenMode ? d.totalTokens : d.totalCost));
const maxValue = Math.max(...values, isTokenMode ? 1 : 0.0001);
// Adaptive scaling: when the spread between largest and smallest non-zero
// values is extreme (>50×), use square-root compression so small bars stay
// visible instead of collapsing to a single pixel.
const nonZero = values.filter((v) => v > 0);
const minNonZero = nonZero.length > 0 ? Math.min(...nonZero) : maxValue;
const spread = maxValue / minNonZero;
const chartAreaPx = 200;
const minBarPx = 6;
const barHeights = values.map((v): number => {
if (v <= 0) {
return 0;
}
const ratio = spread > 50 ? Math.sqrt(v / maxValue) : v / maxValue;
return Math.max(minBarPx, ratio * chartAreaPx);
});
// Calculate bar width based on number of days
const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32;
const showTotals = daily.length <= 14;
@ -206,8 +222,7 @@ function renderDailyChartCompact(
<div class="daily-chart">
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">
${daily.map((d, idx) => {
const value = values[idx];
const heightPct = (value / maxValue) * 100;
const heightPx = barHeights[idx];
const isSelected = selectedDays.includes(d.date);
const label = formatDayLabel(d.date);
// Shorter label for many days (just day number)
@ -257,7 +272,7 @@ function renderDailyChartCompact(
? html`
<div
class="daily-bar daily-bar--stacked"
style="height: ${heightPct.toFixed(1)}%;"
style="height: ${heightPx.toFixed(0)}px;"
>
${(() => {
const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
@ -273,7 +288,7 @@ function renderDailyChartCompact(
</div>
`
: html`
<div class="daily-bar" style="height: ${heightPct.toFixed(1)}%"></div>
<div class="daily-bar" style="height: ${heightPx.toFixed(0)}px"></div>
`
}
${showTotals ? html`<div class="daily-bar-total">${totalLabel}</div>` : nothing}

View File

@ -584,6 +584,7 @@ export function renderUsage(props: UsageProps) {
type="date"
.value=${filters.startDate}
title=${t("usage.filters.startDate")}
aria-label=${t("usage.filters.startDate")}
@change=${(e: Event) =>
filterActions.onStartDateChange((e.target as HTMLInputElement).value)}
/>
@ -593,6 +594,7 @@ export function renderUsage(props: UsageProps) {
type="date"
.value=${filters.endDate}
title=${t("usage.filters.endDate")}
aria-label=${t("usage.filters.endDate")}
@change=${(e: Event) =>
filterActions.onEndDateChange((e.target as HTMLInputElement).value)}
/>
@ -600,6 +602,7 @@ export function renderUsage(props: UsageProps) {
<select
class="usage-select"
title=${t("usage.filters.timeZone")}
aria-label=${t("usage.filters.timeZone")}
.value=${filters.timeZone}
@change=${(e: Event) =>
filterActions.onTimeZoneChange(