mirror of https://github.com/openclaw/openclaw.git
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:
parent
ecb3aa7fe0
commit
a710366e9e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -834,7 +834,7 @@
|
|||
"engines": {
|
||||
"node": ">=22.16.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
"minimumReleaseAge": 2880,
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ function createProps(overrides: Partial<AgentsProps> = {}): AgentsProps {
|
|||
error: null,
|
||||
result: null,
|
||||
},
|
||||
modelCatalog: [],
|
||||
onRefresh: () => undefined,
|
||||
onSelectAgent: () => undefined,
|
||||
onSelectPanel: () => undefined,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("")}
|
||||
>
|
||||
×
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue