feat(ui): dashboard-v2 views refactor (slice 3/3 of dashboard-v2) (#41503)

* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)

New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.

* feat(ui): add utilities, theming, and i18n updates (slice 2 of dashboard-v2)

UI utilities and theming improvements extracted from dashboard-v2-structure:

Icons & formatting:
- icons.ts: expanded icon set for new dashboard views
- format.ts: date/number formatting helpers
- tool-labels.ts: human-readable tool name mappings

Theming:
- theme.ts: enhanced theme resolution and system theme support
- theme-transition.ts: simplified transition logic
- storage.ts: theme parsing improvements for settings persistence

Navigation & types:
- navigation.ts: extended tab definitions for dashboard-v2
- app-view-state.ts: expanded view state management
- types.ts: new type definitions (HealthSummary, ModelCatalogEntry, etc.)

Components:
- components/dashboard-header.ts: reusable header component

i18n:
- Updated en, pt-BR, zh-CN, zh-TW locales with new dashboard strings

All changes are additive or backwards-compatible. Build passes.
Part of #36853.

* feat(ui): dashboard-v2 views refactor (slice 3 of dashboard-v2)

Complete views refactor from dashboard-v2-structure, building on
slice 1 (chat infra, #41497) and slice 2 (utilities/theming, #41500).

Core app wiring:
- app.ts: updated host component with new state properties
- app-render.ts: refactored render pipeline for new dashboard layout
- app-render.helpers.ts: extracted render helpers
- app-settings.ts: theme listener lifecycle fix, cron runs on tab load
- app-gateway.ts: refactored chat event handling
- app-chat.ts: slash command integration

New views:
- views/command-palette.ts: command palette (Cmd+K)
- views/login-gate.ts: authentication gate
- views/bottom-tabs.ts: mobile tab navigation
- views/overview-*.ts: modular overview dashboard (cards, attention,
  event log, hints, log tail, quick actions)
- views/agents-panels-overview.ts: agent overview panel

Refactored views:
- views/chat.ts: major refactor with STT, slash commands, search,
  export, pinned messages, input history
- views/config.ts: restructured config management
- views/agents.ts: streamlined agent management
- views/overview.ts: modular composition from sub-views
- views/sessions.ts: enhanced session management

Controllers:
- controllers/health.ts: new health check controller
- controllers/models.ts: new model catalog controller
- controllers/agents.ts: tools catalog improvements
- controllers/config.ts: config form enhancements

Tests & infrastructure:
- Updated test helpers, browser tests, node tests
- vite.config.ts: build configuration updates
- markdown.ts: rendering improvements

Build passes  | 44 files | +6,626/-1,499
Part of #36853. Depends on #41497 and #41500.

* UI: fix chat review follow-ups

* fix(ui): repair chat clear and attachment regressions

* fix(ui): address remaining chat review comments

* fix(ui): address review follow-ups

* fix(ui): replay queued local slash commands

* fix(ui): repair control-ui type drift

* fix(ui): restore control UI styling

* feat(ui): enhance layout and styling for config and topbar components

- Updated grid layout for the config layout to allow full-width usage.
- Introduced new styles for top tabs and search components to improve usability.
- Added theme mode toggle styling for better visual integration.
- Implemented tests for layout and theme mode components to ensure proper rendering and functionality.

* feat(ui): add config file opening functionality and enhance styles

- Implemented a new handler to open the configuration file using the default application based on the operating system.
- Updated various CSS styles across components for improved visual consistency and usability, including adjustments to padding, margins, and font sizes.
- Introduced new styles for the data table and sidebar components to enhance layout and interaction.
- Added tests for the collapsed navigation rail to ensure proper functionality in different states.

* refactor(ui): update CSS styles for improved layout and consistency

- Simplified font-body declaration in base.css for cleaner code.
- Adjusted transition properties in components.css for better readability.
- Added new .workspace-link class in components.css for enhanced link styling.
- Changed config layout from grid to flex in config.css for better responsiveness.
- Updated related tests to reflect layout changes in config-layout.browser.test.ts.

* feat(ui): enhance theme handling and loading states in chat interface

- Updated CSS to support new theme mode attributes for better styling consistency across light and dark themes.
- Introduced loading skeletons in the chat view to improve user experience during data fetching.
- Refactored command palette to manage focus more effectively, enhancing accessibility.
- Added tests for the appearance theme picker and loading states to ensure proper rendering and functionality.

* refactor(ui): streamline ephemeral state management in chat and config views

- Introduced interfaces for ephemeral state in chat and config views to encapsulate related variables.
- Refactored state management to utilize a single object for better organization and maintainability.
- Removed legacy state variables and updated related functions to reference the new state structure.
- Enhanced readability and consistency across the codebase by standardizing state handling.

* chore: remove test files to reduce PR scope

* fix(ui): resolve type errors in debug props and chat search

* refactor(ui): remove stream mode functionality across various components

- Eliminated stream mode related translations and CSS styles to streamline the user interface.
- Updated multiple components to remove references to stream mode, enhancing code clarity and maintainability.
- Adjusted rendering logic in views to ensure consistent behavior without stream mode.
- Improved overall readability by cleaning up unused variables and props.

* fix(ui): add msg-meta CSS and fix rebase type errors

* fix(ui): add CSS for chat footer action buttons (TTS, delete) and msg-meta

* feat(ui): add delete confirmation with remember-decision checkbox

* fix(ui): delete confirmation with remember, attention icon sizing

* fix(ui): open delete confirm popover to the left (not clipped)

* fix(ui): show all nav items in collapsed sidebar, remove gap

* fix(ui): address P1/P2 review feedback — session queue clear, kill scope, palette guard, stop button

* fix(ui): address Greptile re-review — kill scope, queue flush, idle handling, parallel fetch

- SECURITY: /kill <target> now enforces session tree scope (not just /kill all)
- /kill reports idle sessions gracefully instead of throwing
- Queue continues draining after local slash commands
- /model fetches sessions.list + models.list in parallel (perf fix)

* fix(ui): style update banner close button — SVG stroke + sizing

* fix(ui): update layout styles for sidebar and content spacing

* UI: restore colon slash command parsing

* UI: restore slash command session queries

* Refactor thinking resolution: Introduce resolveThinkingDefaultForModel function and update model-selection to utilize it. Add tests for new functionality in thinking.test.ts.

* fix(ui): constrain welcome state logo size, add missing CSS for new session view

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Val Alexander
2026-03-12 12:46:19 -05:00
committed by GitHub
parent 86135d5889
commit f76a3c5225
73 changed files with 10610 additions and 3112 deletions

8
.gitignore vendored
View File

@@ -123,3 +123,11 @@ dist/protocol.schema.json
# Synthing # Synthing
**/.stfolder/ **/.stfolder/
.dev-state .dev-state
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
.gitignore
test/config-form.analyze.telegram.test.ts
ui/src/ui/theme-variants.browser.test.ts
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png

View File

@@ -1,3 +1,4 @@
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { import {
resolveAgentModelFallbackValues, resolveAgentModelFallbackValues,
@@ -36,7 +37,6 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
"sonnet-4.6": "claude-sonnet-4-6", "sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4.5": "claude-sonnet-4-5", "sonnet-4.5": "claude-sonnet-4-5",
}; };
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
function normalizeAliasKey(value: string): string { function normalizeAliasKey(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
@@ -629,8 +629,8 @@ export function resolveThinkingDefault(params: {
model: string; model: string;
catalog?: ModelCatalogEntry[]; catalog?: ModelCatalogEntry[];
}): ThinkLevel { }): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider); const _normalizedProvider = normalizeProviderId(params.provider);
const modelLower = params.model.toLowerCase(); const _modelLower = params.model.toLowerCase();
const configuredModels = params.cfg.agents?.defaults?.models; const configuredModels = params.cfg.agents?.defaults?.models;
const canonicalKey = modelKey(params.provider, params.model); const canonicalKey = modelKey(params.provider, params.model);
const legacyKey = legacyModelKey(params.provider, params.model); const legacyKey = legacyModelKey(params.provider, params.model);
@@ -652,21 +652,11 @@ export function resolveThinkingDefault(params: {
if (configured) { if (configured) {
return configured; return configured;
} }
const isAnthropicFamilyModel = return resolveThinkingDefaultForModel({
normalizedProvider === "anthropic" || provider: params.provider,
normalizedProvider === "amazon-bedrock" || model: params.model,
modelLower.includes("anthropic/") || catalog: params.catalog,
modelLower.includes(".anthropic."); });
if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) {
return "adaptive";
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
if (candidate?.reasoning) {
return "low";
}
return "off";
} }
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */ /** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */

View File

@@ -4,6 +4,7 @@ import {
listThinkingLevels, listThinkingLevels,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "./thinking.js"; } from "./thinking.js";
describe("normalizeThinkLevel", () => { describe("normalizeThinkLevel", () => {
@@ -84,6 +85,40 @@ describe("listThinkingLevelLabels", () => {
}); });
}); });
describe("resolveThinkingDefaultForModel", () => {
it("defaults Claude 4.6 models to adaptive", () => {
expect(
resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }),
).toBe("adaptive");
});
it("treats Bedrock Anthropic aliases as adaptive", () => {
expect(
resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }),
).toBe("adaptive");
});
it("defaults reasoning-capable catalog models to low", () => {
expect(
resolveThinkingDefaultForModel({
provider: "openai",
model: "gpt-5.4",
catalog: [{ provider: "openai", id: "gpt-5.4", reasoning: true }],
}),
).toBe("low");
});
it("defaults to off when no adaptive or reasoning hint is present", () => {
expect(
resolveThinkingDefaultForModel({
provider: "openai",
model: "gpt-4.1-mini",
catalog: [{ provider: "openai", id: "gpt-4.1-mini", reasoning: false }],
}),
).toBe("off");
});
});
describe("normalizeReasoningLevel", () => { describe("normalizeReasoningLevel", () => {
it("accepts on/off", () => { it("accepts on/off", () => {
expect(normalizeReasoningLevel("on")).toBe("on"); expect(normalizeReasoningLevel("on")).toBe("on");

View File

@@ -5,6 +5,13 @@ export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "tokens" | "full"; export type UsageDisplayLevel = "off" | "tokens" | "full";
export type ThinkingCatalogEntry = {
provider: string;
id: string;
reasoning?: boolean;
};
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
function normalizeProviderId(provider?: string | null): string { function normalizeProviderId(provider?: string | null): string {
if (!provider) { if (!provider) {
@@ -14,6 +21,9 @@ function normalizeProviderId(provider?: string | null): string {
if (normalized === "z.ai" || normalized === "z-ai") { if (normalized === "z.ai" || normalized === "z-ai") {
return "zai"; return "zai";
} }
if (normalized === "bedrock" || normalized === "aws-bedrock") {
return "amazon-bedrock";
}
return normalized; return normalized;
} }
@@ -130,6 +140,30 @@ export function formatXHighModelHint(): string {
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`; return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
} }
export function resolveThinkingDefaultForModel(params: {
provider: string;
model: string;
catalog?: ThinkingCatalogEntry[];
}): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider);
const modelLower = params.model.trim().toLowerCase();
const isAnthropicFamilyModel =
normalizedProvider === "anthropic" ||
normalizedProvider === "amazon-bedrock" ||
modelLower.includes("anthropic/") ||
modelLower.includes(".anthropic.");
if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) {
return "adaptive";
}
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
);
if (candidate?.reasoning) {
return "low";
}
return "off";
}
type OnOffFullLevel = "off" | "on" | "full"; type OnOffFullLevel = "off" | "on" | "full";
function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined { function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined {

View File

@@ -104,8 +104,8 @@ export const TelegramDirectSchema = z
const TelegramCustomCommandSchema = z const TelegramCustomCommandSchema = z
.object({ .object({
command: z.string().transform(normalizeTelegramCommandName), command: z.string().overwrite(normalizeTelegramCommandName),
description: z.string().transform(normalizeTelegramCommandDescription), description: z.string().overwrite(normalizeTelegramCommandDescription),
}) })
.strict(); .strict();

View File

@@ -1,3 +1,4 @@
import { exec } from "node:child_process";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPlugins } from "../../channels/plugins/index.js"; import { listChannelPlugins } from "../../channels/plugins/index.js";
import { import {
@@ -529,4 +530,19 @@ export const configHandlers: GatewayRequestHandlers = {
undefined, undefined,
); );
}, },
"config.openFile": ({ params, respond }) => {
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {
return;
}
const configPath = createConfigIO().configPath;
const platform = process.platform;
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
exec(`${cmd} ${JSON.stringify(configPath)}`, (err) => {
if (err) {
respond(true, { ok: false, path: configPath, error: err.message }, undefined);
return;
}
respond(true, { ok: true, path: configPath }, undefined);
});
},
}; };

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const en: TranslationMap = { export const en: TranslationMap = {
common: { common: {
version: "Version",
health: "Health", health: "Health",
ok: "OK", ok: "OK",
offline: "Offline", offline: "Offline",
@@ -147,10 +146,6 @@ export const en: TranslationMap = {
refreshAll: "Refresh All", refreshAll: "Refresh All",
terminal: "Terminal", terminal: "Terminal",
}, },
streamMode: {
active: "Stream mode — values redacted",
disable: "Disable",
},
palette: { palette: {
placeholder: "Type a command…", placeholder: "Type a command…",
noResults: "No results", noResults: "No results",
@@ -158,7 +153,7 @@ export const en: TranslationMap = {
}, },
login: { login: {
subtitle: "Gateway Dashboard", subtitle: "Gateway Dashboard",
passwordPlaceholder: "optional", // pragma: allowlist secret passwordPlaceholder: "optional",
}, },
chat: { chat: {
disconnected: "Disconnected from gateway.", disconnected: "Disconnected from gateway.",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const pt_BR: TranslationMap = { export const pt_BR: TranslationMap = {
common: { common: {
version: "Versão",
health: "Saúde", health: "Saúde",
ok: "OK", ok: "OK",
offline: "Offline", offline: "Offline",
@@ -12,7 +11,6 @@ export const pt_BR: TranslationMap = {
disabled: "Desativado", disabled: "Desativado",
na: "n/a", na: "n/a",
docs: "Docs", docs: "Docs",
theme: "Tema",
resources: "Recursos", resources: "Recursos",
search: "Pesquisar", search: "Pesquisar",
}, },
@@ -149,10 +147,6 @@ export const pt_BR: TranslationMap = {
refreshAll: "Atualizar Tudo", refreshAll: "Atualizar Tudo",
terminal: "Terminal", terminal: "Terminal",
}, },
streamMode: {
active: "Modo stream — valores ocultos",
disable: "Desativar",
},
palette: { palette: {
placeholder: "Digite um comando…", placeholder: "Digite um comando…",
noResults: "Sem resultados", noResults: "Sem resultados",
@@ -160,7 +154,7 @@ export const pt_BR: TranslationMap = {
}, },
login: { login: {
subtitle: "Painel do Gateway", subtitle: "Painel do Gateway",
passwordPlaceholder: "opcional", // pragma: allowlist secret passwordPlaceholder: "opcional",
}, },
chat: { chat: {
disconnected: "Desconectado do gateway.", disconnected: "Desconectado do gateway.",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_CN: TranslationMap = { export const zh_CN: TranslationMap = {
common: { common: {
version: "版本",
health: "健康状况", health: "健康状况",
ok: "正常", ok: "正常",
offline: "离线", offline: "离线",
@@ -12,7 +11,6 @@ export const zh_CN: TranslationMap = {
disabled: "已禁用", disabled: "已禁用",
na: "不适用", na: "不适用",
docs: "文档", docs: "文档",
theme: "主题",
resources: "资源", resources: "资源",
search: "搜索", search: "搜索",
}, },
@@ -146,10 +144,6 @@ export const zh_CN: TranslationMap = {
refreshAll: "全部刷新", refreshAll: "全部刷新",
terminal: "终端", terminal: "终端",
}, },
streamMode: {
active: "流模式 — 数据已隐藏",
disable: "禁用",
},
palette: { palette: {
placeholder: "输入命令…", placeholder: "输入命令…",
noResults: "无结果", noResults: "无结果",

View File

@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
export const zh_TW: TranslationMap = { export const zh_TW: TranslationMap = {
common: { common: {
version: "版本",
health: "健康狀況", health: "健康狀況",
ok: "正常", ok: "正常",
offline: "離線", offline: "離線",
@@ -12,7 +11,6 @@ export const zh_TW: TranslationMap = {
disabled: "已禁用", disabled: "已禁用",
na: "不適用", na: "不適用",
docs: "文檔", docs: "文檔",
theme: "主題",
resources: "資源", resources: "資源",
search: "搜尋", search: "搜尋",
}, },
@@ -146,10 +144,6 @@ export const zh_TW: TranslationMap = {
refreshAll: "全部刷新", refreshAll: "全部刷新",
terminal: "終端", terminal: "終端",
}, },
streamMode: {
active: "串流模式 — 數據已隱藏",
disable: "禁用",
},
palette: { palette: {
placeholder: "輸入指令…", placeholder: "輸入指令…",
noResults: "無結果", noResults: "無結果",

View File

@@ -2,4 +2,5 @@
@import "./styles/layout.css"; @import "./styles/layout.css";
@import "./styles/layout.mobile.css"; @import "./styles/layout.mobile.css";
@import "./styles/components.css"; @import "./styles/components.css";
@import "./styles/chat.css";
@import "./styles/config.css"; @import "./styles/config.css";

View File

@@ -1,78 +1,78 @@
:root { :root {
/* Background - Warmer dark with depth */ /* Background - Deep, rich dark with layered depth */
--bg: #12141a; --bg: #0e1015;
--bg-accent: #14161d; --bg-accent: #13151b;
--bg-elevated: #1a1d25; --bg-elevated: #191c24;
--bg-hover: #262a35; --bg-hover: #1f2330;
--bg-muted: #262a35; --bg-muted: #1f2330;
/* Card / Surface - More contrast between levels */ /* Card / Surface - Clear hierarchy between levels */
--card: #181b22; --card: #161920;
--card-foreground: #f4f4f5; --card-foreground: #f0f0f2;
--card-highlight: rgba(255, 255, 255, 0.05); --card-highlight: rgba(255, 255, 255, 0.04);
--popover: #181b22; --popover: #191c24;
--popover-foreground: #f4f4f5; --popover-foreground: #f0f0f2;
/* Panel */ /* Panel */
--panel: #12141a; --panel: #0e1015;
--panel-strong: #1a1d25; --panel-strong: #191c24;
--panel-hover: #262a35; --panel-hover: #1f2330;
--chrome: rgba(18, 20, 26, 0.95); --chrome: rgba(14, 16, 21, 0.96);
--chrome-strong: rgba(18, 20, 26, 0.98); --chrome-strong: rgba(14, 16, 21, 0.98);
/* Text - Slightly warmer */ /* Text - Clean contrast */
--text: #e4e4e7; --text: #d4d4d8;
--text-strong: #fafafa; --text-strong: #f4f4f5;
--chat-text: #e4e4e7; --chat-text: #d4d4d8;
--muted: #71717a; --muted: #636370;
--muted-strong: #52525b; --muted-strong: #4e4e5a;
--muted-foreground: #71717a; --muted-foreground: #636370;
/* Border - Subtle but defined */ /* Border - Whisper-thin, barely there */
--border: #27272a; --border: #1e2028;
--border-strong: #3f3f46; --border-strong: #2e3040;
--border-hover: #52525b; --border-hover: #3e4050;
--input: #27272a; --input: #1e2028;
--ring: #ff5c5c; --ring: #ff5c5c;
/* Accent - Punchy signature red */ /* Accent - Punchy signature red */
--accent: #ff5c5c; --accent: #ff5c5c;
--accent-hover: #ff7070; --accent-hover: #ff7070;
--accent-muted: #ff5c5c; --accent-muted: #ff5c5c;
--accent-subtle: rgba(255, 92, 92, 0.15); --accent-subtle: rgba(255, 92, 92, 0.1);
--accent-foreground: #fafafa; --accent-foreground: #fafafa;
--accent-glow: rgba(255, 92, 92, 0.25); --accent-glow: rgba(255, 92, 92, 0.2);
--primary: #ff5c5c; --primary: #ff5c5c;
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
/* Secondary - Teal accent for variety */ /* Secondary */
--secondary: #1e2028; --secondary: #161920;
--secondary-foreground: #f4f4f5; --secondary-foreground: #f0f0f2;
--accent-2: #14b8a6; --accent-2: #14b8a6;
--accent-2-muted: rgba(20, 184, 166, 0.7); --accent-2-muted: rgba(20, 184, 166, 0.7);
--accent-2-subtle: rgba(20, 184, 166, 0.15); --accent-2-subtle: rgba(20, 184, 166, 0.1);
/* Semantic - More saturated */ /* Semantic */
--ok: #22c55e; --ok: #22c55e;
--ok-muted: rgba(34, 197, 94, 0.75); --ok-muted: rgba(34, 197, 94, 0.75);
--ok-subtle: rgba(34, 197, 94, 0.12); --ok-subtle: rgba(34, 197, 94, 0.08);
--destructive: #ef4444; --destructive: #ef4444;
--destructive-foreground: #fafafa; --destructive-foreground: #fafafa;
--warn: #f59e0b; --warn: #f59e0b;
--warn-muted: rgba(245, 158, 11, 0.75); --warn-muted: rgba(245, 158, 11, 0.75);
--warn-subtle: rgba(245, 158, 11, 0.12); --warn-subtle: rgba(245, 158, 11, 0.08);
--danger: #ef4444; --danger: #ef4444;
--danger-muted: rgba(239, 68, 68, 0.75); --danger-muted: rgba(239, 68, 68, 0.75);
--danger-subtle: rgba(239, 68, 68, 0.12); --danger-subtle: rgba(239, 68, 68, 0.08);
--info: #3b82f6; --info: #3b82f6;
/* Focus - With glow */ /* Focus */
--focus: rgba(255, 92, 92, 0.25); --focus: rgba(255, 92, 92, 0.2);
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow); --focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
/* Grid */ /* Grid */
--grid-line: rgba(255, 255, 255, 0.04); --grid-line: rgba(255, 255, 255, 0.03);
/* Theme transition */ /* Theme transition */
--theme-switch-x: 50%; --theme-switch-x: 50%;
@@ -81,111 +81,153 @@
/* Typography */ /* Typography */
--mono: --mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: var(--font-body); --font-display: var(--font-body);
/* Shadows - Richer with subtle color */ /* Shadows - Subtle, layered depth */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03); --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03); --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03); --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 30px var(--accent-glow); --shadow-glow: 0 0 24px var(--accent-glow);
/* Radii - Slightly larger for friendlier feel */ /* Radii - Slightly larger for modern feel */
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 8px; --radius-md: 10px;
--radius-lg: 12px; --radius-lg: 14px;
--radius-xl: 16px; --radius-xl: 20px;
--radius-full: 9999px; --radius-full: 9999px;
--radius: 8px; --radius: 10px;
/* Transitions - Snappy but smooth */ /* Transitions - Crisp and responsive */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--duration-fast: 120ms; --duration-fast: 100ms;
--duration-normal: 200ms; --duration-normal: 180ms;
--duration-slow: 350ms; --duration-slow: 300ms;
color-scheme: dark; color-scheme: dark;
} }
/* Light theme - Clean with subtle warmth */ /* Light theme tokens apply to every light-mode family. */
:root[data-theme="light"] { :root[data-theme-mode="light"] {
--bg: #fafafa; --bg: #f8f9fa;
--bg-accent: #f5f5f5; --bg-accent: #f1f3f5;
--bg-elevated: #ffffff; --bg-elevated: #ffffff;
--bg-hover: #f0f0f0; --bg-hover: #eceef0;
--bg-muted: #f0f0f0; --bg-muted: #eceef0;
--bg-content: #f5f5f5; --bg-content: #f1f3f5;
--card: #ffffff; --card: #ffffff;
--card-foreground: #18181b; --card-foreground: #1a1a1e;
--card-highlight: rgba(0, 0, 0, 0.03); --card-highlight: rgba(0, 0, 0, 0.02);
--popover: #ffffff; --popover: #ffffff;
--popover-foreground: #18181b; --popover-foreground: #1a1a1e;
--panel: #fafafa; --panel: #f8f9fa;
--panel-strong: #f5f5f5; --panel-strong: #f1f3f5;
--panel-hover: #ebebeb; --panel-hover: #e6e8eb;
--chrome: rgba(250, 250, 250, 0.95); --chrome: rgba(248, 249, 250, 0.96);
--chrome-strong: rgba(250, 250, 250, 0.98); --chrome-strong: rgba(248, 249, 250, 0.98);
--text: #3f3f46; --text: #3c3c43;
--text-strong: #18181b; --text-strong: #1a1a1e;
--chat-text: #3f3f46; --chat-text: #3c3c43;
--muted: #71717a; --muted: #8e8e93;
--muted-strong: #52525b; --muted-strong: #636366;
--muted-foreground: #71717a; --muted-foreground: #8e8e93;
--border: #e4e4e7; --border: #e5e5ea;
--border-strong: #d4d4d8; --border-strong: #d1d1d6;
--border-hover: #a1a1aa; --border-hover: #aeaeb2;
--input: #e4e4e7; --input: #e5e5ea;
--accent: #dc2626; --accent: #dc2626;
--accent-hover: #ef4444; --accent-hover: #ef4444;
--accent-muted: #dc2626; --accent-muted: #dc2626;
--accent-subtle: rgba(220, 38, 38, 0.12); --accent-subtle: rgba(220, 38, 38, 0.08);
--accent-foreground: #ffffff; --accent-foreground: #ffffff;
--accent-glow: rgba(220, 38, 38, 0.15); --accent-glow: rgba(220, 38, 38, 0.1);
--primary: #dc2626; --primary: #dc2626;
--primary-foreground: #ffffff; --primary-foreground: #ffffff;
--secondary: #f4f4f5; --secondary: #f1f3f5;
--secondary-foreground: #3f3f46; --secondary-foreground: #3c3c43;
--accent-2: #0d9488; --accent-2: #0d9488;
--accent-2-muted: rgba(13, 148, 136, 0.75); --accent-2-muted: rgba(13, 148, 136, 0.75);
--accent-2-subtle: rgba(13, 148, 136, 0.12); --accent-2-subtle: rgba(13, 148, 136, 0.08);
--ok: #16a34a; --ok: #16a34a;
--ok-muted: rgba(22, 163, 74, 0.75); --ok-muted: rgba(22, 163, 74, 0.75);
--ok-subtle: rgba(22, 163, 74, 0.1); --ok-subtle: rgba(22, 163, 74, 0.08);
--destructive: #dc2626; --destructive: #dc2626;
--destructive-foreground: #fafafa; --destructive-foreground: #fafafa;
--warn: #d97706; --warn: #d97706;
--warn-muted: rgba(217, 119, 6, 0.75); --warn-muted: rgba(217, 119, 6, 0.75);
--warn-subtle: rgba(217, 119, 6, 0.1); --warn-subtle: rgba(217, 119, 6, 0.08);
--danger: #dc2626; --danger: #dc2626;
--danger-muted: rgba(220, 38, 38, 0.75); --danger-muted: rgba(220, 38, 38, 0.75);
--danger-subtle: rgba(220, 38, 38, 0.1); --danger-subtle: rgba(220, 38, 38, 0.08);
--info: #2563eb; --info: #2563eb;
--focus: rgba(220, 38, 38, 0.2); --focus: rgba(220, 38, 38, 0.15);
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow); --focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, 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.05); --grid-line: rgba(0, 0, 0, 0.04);
/* Light shadows */ /* Light shadows - Subtle, clean */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08);
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04); --shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.1);
--shadow-glow: 0 0 24px var(--accent-glow); --shadow-glow: 0 0 20px var(--accent-glow);
color-scheme: light; color-scheme: light;
} }
/* Theme families override accent tokens while keeping shared surfaces/layout. */
:root[data-theme="openknot"] {
--ring: #14b8a6;
--accent: #14b8a6;
--accent-hover: #2dd4bf;
--accent-muted: #14b8a6;
--accent-subtle: rgba(20, 184, 166, 0.12);
--accent-glow: rgba(20, 184, 166, 0.22);
--primary: #14b8a6;
}
:root[data-theme="openknot-light"] {
--ring: #0d9488;
--accent: #0d9488;
--accent-hover: #0f766e;
--accent-muted: #0d9488;
--accent-subtle: rgba(13, 148, 136, 0.1);
--accent-glow: rgba(13, 148, 136, 0.14);
--primary: #0d9488;
}
:root[data-theme="dash"] {
--ring: #3b82f6;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--accent-muted: #3b82f6;
--accent-subtle: rgba(59, 130, 246, 0.14);
--accent-glow: rgba(59, 130, 246, 0.22);
--primary: #3b82f6;
}
:root[data-theme="dash-light"] {
--ring: #2563eb;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-muted: #2563eb;
--accent-subtle: rgba(37, 99, 235, 0.1);
--accent-glow: rgba(37, 99, 235, 0.14);
--primary: #2563eb;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -197,8 +239,8 @@ body {
body { body {
margin: 0; margin: 0;
font: 400 14px/1.55 var(--font-body); font: 400 13.5px/1.55 var(--font-body);
letter-spacing: -0.02em; letter-spacing: -0.01em;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -267,10 +309,10 @@ select {
color: var(--text-strong); color: var(--text-strong);
} }
/* Scrollbar styling */ /* Scrollbar styling - Minimal, barely visible */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 6px;
height: 8px; height: 6px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -278,12 +320,12 @@ select {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border); background: rgba(255, 255, 255, 0.08);
border-radius: var(--radius-full); border-radius: var(--radius-full);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--border-strong); background: rgba(255, 255, 255, 0.14);
} }
/* Animations - Polished with spring feel */ /* Animations - Polished with spring feel */
@@ -338,6 +380,42 @@ select {
} }
} }
/* Skeleton loading primitives */
.skeleton {
background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-md);
}
.skeleton-line {
height: 14px;
border-radius: var(--radius-sm);
}
.skeleton-line--short {
width: 40%;
}
.skeleton-line--medium {
width: 65%;
}
.skeleton-line--long {
width: 85%;
}
.skeleton-stat {
height: 28px;
width: 60px;
border-radius: var(--radius-sm);
}
.skeleton-block {
height: 48px;
border-radius: var(--radius-md);
}
@keyframes pulse-subtle { @keyframes pulse-subtle {
0%, 0%,
100% { 100% {

View File

@@ -5,9 +5,9 @@
/* Chat Group Layout - default (assistant/other on left) */ /* Chat Group Layout - default (assistant/other on left) */
.chat-group { .chat-group {
display: flex; display: flex;
gap: 12px; gap: 10px;
align-items: flex-start; align-items: flex-start;
margin-bottom: 16px; margin-bottom: 14px;
margin-left: 4px; margin-left: 4px;
margin-right: 16px; margin-right: 16px;
} }
@@ -54,6 +54,52 @@
opacity: 0.7; opacity: 0.7;
} }
/* ── Group footer action buttons (TTS, delete) ── */
.chat-group-footer button {
background: none;
border: none;
cursor: pointer;
padding: 2px;
border-radius: var(--radius-sm, 4px);
color: var(--muted);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, color 120ms ease-out, background 120ms ease-out;
display: inline-flex;
align-items: center;
justify-content: center;
}
.chat-group:hover .chat-group-footer button {
opacity: 0.6;
pointer-events: auto;
}
.chat-group-footer button:hover {
opacity: 1 !important;
background: var(--bg-hover, rgba(255,255,255,0.08));
}
.chat-group-footer button svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tts-btn--active {
opacity: 1 !important;
pointer-events: auto !important;
color: var(--accent, #3b82f6);
}
.chat-group-delete:hover {
color: var(--danger, #ef4444) !important;
}
/* Chat divider (e.g., compaction marker) */ /* Chat divider (e.g., compaction marker) */
.chat-divider { .chat-divider {
display: flex; display: flex;
@@ -83,22 +129,24 @@
/* Avatar Styles */ /* Avatar Styles */
.chat-avatar { .chat-avatar {
width: 40px; width: 36px;
height: 40px; height: 36px;
border-radius: 8px; border-radius: 10px;
background: var(--panel-strong); background: var(--panel-strong);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 13px;
flex-shrink: 0; flex-shrink: 0;
align-self: flex-end; /* Align with last message in group */ align-self: flex-end;
margin-bottom: 4px; /* Optical alignment */ margin-bottom: 4px;
border: 1px solid var(--border);
} }
.chat-avatar.user { .chat-avatar.user {
background: var(--accent-subtle); background: var(--accent-subtle);
color: var(--accent); color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
} }
.chat-avatar.assistant { .chat-avatar.assistant {
@@ -127,14 +175,14 @@ img.chat-avatar {
.chat-bubble { .chat-bubble {
position: relative; position: relative;
display: inline-block; display: inline-block;
border: 1px solid transparent; border: 1px solid var(--border);
background: var(--card); background: var(--card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 10px 14px; padding: 10px 14px;
box-shadow: none; box-shadow: none;
transition: transition:
background 150ms ease-out, background var(--duration-fast) ease-out,
border-color 150ms ease-out; border-color var(--duration-fast) ease-out;
max-width: 100%; max-width: 100%;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -244,7 +292,7 @@ img.chat-avatar {
} }
/* Light mode: restore borders */ /* Light mode: restore borders */
:root[data-theme="light"] .chat-bubble { :root[data-theme-mode="light"] .chat-bubble {
border-color: var(--border); border-color: var(--border);
box-shadow: inset 0 1px 0 var(--card-highlight); box-shadow: inset 0 1px 0 var(--card-highlight);
} }
@@ -259,7 +307,7 @@ img.chat-avatar {
border-color: transparent; border-color: transparent;
} }
:root[data-theme="light"] .chat-group.user .chat-bubble { :root[data-theme-mode="light"] .chat-group.user .chat-bubble {
border-color: rgba(234, 88, 12, 0.2); border-color: rgba(234, 88, 12, 0.2);
background: rgba(251, 146, 60, 0.12); background: rgba(251, 146, 60, 0.12);
} }
@@ -298,3 +346,125 @@ img.chat-avatar {
transform: translateY(0); transform: translateY(0);
} }
} }
/* ── Message metadata (tokens, cost, model, context %) ── */
.msg-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
line-height: 1;
color: var(--muted);
margin-top: 4px;
flex-wrap: wrap;
}
.msg-meta__tokens,
.msg-meta__cache,
.msg-meta__cost,
.msg-meta__ctx,
.msg-meta__model {
display: inline-flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.msg-meta__model {
background: var(--bg-hover, rgba(255,255,255,0.06));
padding: 1px 6px;
border-radius: var(--radius-sm, 4px);
font-family: var(--font-mono, monospace);
}
.msg-meta__cost {
color: var(--ok, #22c55e);
}
.msg-meta__ctx--warn {
color: var(--warning, #eab308);
}
.msg-meta__ctx--danger {
color: var(--danger, #ef4444);
}
/* ── Delete confirmation popover ── */
.chat-delete-wrap {
position: relative;
display: inline-flex;
}
.chat-delete-confirm {
position: absolute;
bottom: calc(100% + 6px);
left: 0;
background: var(--card, #1a1a1a);
border: 1px solid var(--border, rgba(255,255,255,0.1));
border-radius: var(--radius-md, 8px);
padding: 12px;
min-width: 200px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 100;
animation: scale-in 0.15s ease-out;
}
.chat-delete-confirm__text {
margin: 0 0 8px;
font-size: 13px;
font-weight: 500;
color: var(--fg, #fff);
}
.chat-delete-confirm__remember {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted, #888);
margin-bottom: 10px;
cursor: pointer;
user-select: none;
}
.chat-delete-confirm__check {
width: 14px;
height: 14px;
accent-color: var(--accent, #3b82f6);
cursor: pointer;
}
.chat-delete-confirm__actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.chat-delete-confirm__cancel,
.chat-delete-confirm__yes {
border: none;
border-radius: var(--radius-sm, 4px);
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 120ms ease-out;
}
.chat-delete-confirm__cancel {
background: var(--bg-hover, rgba(255,255,255,0.08));
color: var(--muted, #888);
}
.chat-delete-confirm__cancel:hover {
background: rgba(255,255,255,0.12);
}
.chat-delete-confirm__yes {
background: var(--danger, #ef4444);
color: #fff;
}
.chat-delete-confirm__yes:hover {
background: #dc2626;
}

View File

@@ -219,17 +219,17 @@
} }
/* Light theme attachment overrides */ /* Light theme attachment overrides */
:root[data-theme="light"] .chat-attachments { :root[data-theme-mode="light"] .chat-attachments {
background: #f8fafc; background: #f8fafc;
border-color: rgba(16, 24, 40, 0.1); border-color: rgba(16, 24, 40, 0.1);
} }
:root[data-theme="light"] .chat-attachment { :root[data-theme-mode="light"] .chat-attachment {
border-color: rgba(16, 24, 40, 0.15); border-color: rgba(16, 24, 40, 0.15);
background: #fff; background: #fff;
} }
:root[data-theme="light"] .chat-attachment__remove { :root[data-theme-mode="light"] .chat-attachment__remove {
background: rgba(0, 0, 0, 0.6); background: rgba(0, 0, 0, 0.6);
} }
@@ -267,7 +267,7 @@
flex: 1; flex: 1;
} }
:root[data-theme="light"] .chat-compose { :root[data-theme-mode="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%); background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
} }
@@ -322,6 +322,340 @@
box-sizing: border-box; box-sizing: border-box;
} }
.agent-chat__input {
position: relative;
display: flex;
flex-direction: column;
margin: 0 18px 14px;
padding: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
flex-shrink: 0;
overflow: hidden;
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.agent-chat__input:focus-within {
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent);
}
@supports (backdrop-filter: blur(1px)) {
.agent-chat__input {
backdrop-filter: blur(12px) saturate(1.6);
-webkit-backdrop-filter: blur(12px) saturate(1.6);
}
}
.agent-chat__input > textarea {
width: 100%;
min-height: 40px;
max-height: 150px;
resize: none;
padding: 12px 14px 8px;
border: none;
background: transparent;
color: var(--text);
font-size: 0.92rem;
font-family: inherit;
line-height: 1.4;
outline: none;
box-sizing: border-box;
}
.agent-chat__input > textarea::placeholder {
color: var(--muted);
}
.agent-chat__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.agent-chat__toolbar-left,
.agent-chat__toolbar-right {
display: flex;
align-items: center;
gap: 4px;
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
flex-shrink: 0;
padding: 0;
transition: all var(--duration-fast) ease;
}
.agent-chat__input-btn svg,
.agent-chat__toolbar .btn-ghost svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.agent-chat__input-btn:hover:not(:disabled),
.agent-chat__toolbar .btn-ghost:hover:not(:disabled) {
color: var(--text);
background: var(--bg-hover);
}
.agent-chat__input-btn:disabled,
.agent-chat__toolbar .btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.agent-chat__input-btn--active {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.agent-chat__input-divider {
width: 1px;
height: 16px;
background: var(--border);
margin: 0 4px;
}
.agent-chat__token-count {
font-size: 0.7rem;
color: var(--muted);
white-space: nowrap;
flex-shrink: 0;
align-self: center;
}
.chat-send-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-md);
border: none;
background: var(--accent);
color: var(--accent-foreground);
cursor: pointer;
flex-shrink: 0;
transition:
background var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
padding: 0;
}
.chat-send-btn svg {
width: 15px;
height: 15px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-send-btn:hover:not(:disabled) {
background: var(--accent-hover);
box-shadow: 0 2px 10px rgba(255, 92, 92, 0.25);
}
.chat-send-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.chat-send-btn--stop {
background: var(--danger);
}
.chat-send-btn--stop:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 85%, #fff);
}
.slash-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
background: var(--popover);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 30;
margin-bottom: 4px;
padding: 6px;
scrollbar-width: thin;
}
.slash-menu-group + .slash-menu-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.slash-menu-group__label {
padding: 4px 10px 2px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
opacity: 0.7;
}
.slash-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.slash-menu-item:hover,
.slash-menu-item--active {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover));
}
.slash-menu-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--accent);
opacity: 0.7;
}
.slash-menu-icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.slash-menu-item--active .slash-menu-icon,
.slash-menu-item:hover .slash-menu-icon {
opacity: 1;
}
.slash-menu-name {
font-size: 0.82rem;
font-weight: 600;
font-family: var(--mono);
color: var(--accent);
white-space: nowrap;
}
.slash-menu-args {
font-size: 0.75rem;
color: var(--muted);
font-family: var(--mono);
opacity: 0.65;
}
.slash-menu-desc {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-size: 0.75rem;
color: var(--muted);
}
.slash-menu-item--active .slash-menu-name {
color: var(--accent-hover);
}
.slash-menu-item--active .slash-menu-desc {
color: var(--text);
}
.chat-attachments-preview {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.chat-attachment-thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: var(--radius-sm);
overflow: hidden;
border: 1px solid var(--border);
}
.chat-attachment-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.chat-attachment-file {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.72rem;
color: var(--muted);
}
.agent-chat__file-input {
display: none;
}
/* Chat controls - moved to content-header area, left aligned */ /* Chat controls - moved to content-header area, left aligned */
.chat-controls { .chat-controls {
display: flex; display: flex;
@@ -363,7 +697,7 @@
font-weight: 300; font-weight: 300;
} }
:root[data-theme="light"] .chat-controls__separator { :root[data-theme-mode="light"] .chat-controls__separator {
color: rgba(16, 24, 40, 0.3); color: rgba(16, 24, 40, 0.3);
} }
@@ -373,34 +707,34 @@
} }
/* Light theme icon button overrides */ /* Light theme icon button overrides */
:root[data-theme="light"] .btn--icon { :root[data-theme-mode="light"] .btn--icon {
background: #ffffff; background: #ffffff;
border-color: var(--border); border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted); color: var(--muted);
} }
:root[data-theme="light"] .btn--icon:hover { :root[data-theme-mode="light"] .btn--icon:hover {
background: #ffffff; background: #ffffff;
border-color: var(--border-strong); border-color: var(--border-strong);
color: var(--text); color: var(--text);
} }
/* Light theme icon button overrides */ /* Light theme icon button overrides */
:root[data-theme="light"] .btn--icon { :root[data-theme-mode="light"] .btn--icon {
background: #ffffff; background: #ffffff;
border-color: var(--border); border-color: var(--border);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05); box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
color: var(--muted); color: var(--muted);
} }
:root[data-theme="light"] .btn--icon:hover { :root[data-theme-mode="light"] .btn--icon:hover {
background: #ffffff; background: #ffffff;
border-color: var(--border-strong); border-color: var(--border-strong);
color: var(--text); color: var(--text);
} }
:root[data-theme="light"] .chat-controls .btn--icon.active { :root[data-theme-mode="light"] .chat-controls .btn--icon.active {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-subtle); background: var(--accent-subtle);
color: var(--accent); color: var(--accent);
@@ -438,7 +772,7 @@
} }
/* Light theme thinking indicator override */ /* Light theme thinking indicator override */
:root[data-theme="light"] .chat-controls__thinking { :root[data-theme-mode="light"] .chat-controls__thinking {
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9);
border-color: rgba(16, 24, 40, 0.15); border-color: rgba(16, 24, 40, 0.15);
} }
@@ -479,3 +813,117 @@
min-width: 120px; min-width: 120px;
} }
} }
/* Chat loading skeleton */
.chat-loading-skeleton {
padding: 4px 0;
animation: fade-in 0.3s var(--ease-out);
}
/* Welcome state (new session) */
.agent-chat__welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 12px;
padding: 48px 24px;
flex: 1;
min-height: 0;
}
.agent-chat__welcome-glow {
display: none;
}
.agent-chat__welcome h2 {
font-size: 20px;
font-weight: 600;
margin: 0;
color: var(--foreground);
}
.agent-chat__avatar--logo {
width: 48px;
height: 48px;
border-radius: 14px;
background: var(--panel-strong);
border: 1px solid var(--border);
display: grid;
place-items: center;
overflow: hidden;
}
.agent-chat__avatar--logo img {
width: 32px;
height: 32px;
object-fit: contain;
}
.agent-chat__badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.agent-chat__badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 100px;
padding: 4px 12px;
}
.agent-chat__badge img {
width: 14px;
height: 14px;
object-fit: contain;
}
.agent-chat__hint {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.agent-chat__hint kbd {
display: inline-block;
padding: 1px 6px;
font-size: 11px;
font-family: var(--font-mono);
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 4px;
}
.agent-chat__suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 480px;
margin-top: 8px;
}
.agent-chat__suggestion {
font-size: 13px;
padding: 8px 16px;
border-radius: 100px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--foreground);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.agent-chat__suggestion:hover {
background: var(--panel-strong);
border-color: var(--accent);
}

View File

@@ -13,7 +13,7 @@
line-height: 1.4; line-height: 1.4;
} }
:root[data-theme="light"] .chat-thinking { :root[data-theme-mode="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.25); border-color: rgba(16, 24, 40, 0.25);
background: rgba(16, 24, 40, 0.04); background: rgba(16, 24, 40, 0.04);
} }
@@ -97,24 +97,24 @@
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
:root[data-theme="light"] .chat-text :where(blockquote) { :root[data-theme-mode="light"] .chat-text :where(blockquote) {
background: rgba(0, 0, 0, 0.03); background: rgba(0, 0, 0, 0.03);
} }
:root[data-theme="light"] .chat-text :where(blockquote blockquote) { :root[data-theme-mode="light"] .chat-text :where(blockquote blockquote) {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) { :root[data-theme-mode="light"] .chat-text :where(blockquote blockquote blockquote) {
background: rgba(0, 0, 0, 0.04); background: rgba(0, 0, 0, 0.04);
} }
:root[data-theme="light"] .chat-text :where(:not(pre) > code) { :root[data-theme-mode="light"] .chat-text :where(:not(pre) > code) {
background: rgba(0, 0, 0, 0.08); background: rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
} }
:root[data-theme="light"] .chat-text :where(pre) { :root[data-theme-mode="light"] .chat-text :where(pre) {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
} }

View File

@@ -1,15 +1,13 @@
/* Tool Card Styles */ /* Tool Card Styles */
.chat-tool-card { .chat-tool-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius-md);
padding: 12px; padding: 10px 12px;
margin-top: 8px; margin-top: 6px;
background: var(--card); background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition: transition:
border-color 150ms ease-out, border-color var(--duration-fast) ease-out,
background 150ms ease-out; background var(--duration-fast) ease-out;
/* Fixed max-height to ensure cards don't expand too much */
max-height: 120px; max-height: 120px;
overflow: hidden; overflow: hidden;
} }
@@ -154,6 +152,265 @@
word-break: break-word; word-break: break-word;
} }
.chat-tools-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--muted);
user-select: none;
list-style: none;
transition:
color 150ms ease,
background 150ms ease;
}
.chat-tools-summary::-webkit-details-marker {
display: none;
}
.chat-tools-summary::before {
content: "▸";
font-size: 10px;
flex-shrink: 0;
transition: transform 150ms ease;
}
.chat-tools-collapse[open] > .chat-tools-summary::before {
transform: rotate(90deg);
}
.chat-tools-summary:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
}
.chat-tools-summary__icon {
display: inline-flex;
align-items: center;
width: 14px;
height: 14px;
color: var(--accent);
opacity: 0.7;
flex-shrink: 0;
}
.chat-tools-summary__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tools-summary__count {
font-weight: 600;
color: var(--text);
}
.chat-tools-summary__names {
color: var(--muted);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tools-collapse__body {
padding: 4px 12px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.chat-tools-collapse__body .chat-tool-card:first-child {
margin-top: 8px;
}
.chat-json-collapse {
margin-top: 4px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--secondary) 60%, transparent);
overflow: hidden;
}
.chat-json-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
user-select: none;
list-style: none;
transition:
color 150ms ease,
background 150ms ease;
}
.chat-json-summary::-webkit-details-marker {
display: none;
}
.chat-json-summary::before {
content: "▸";
font-size: 10px;
flex-shrink: 0;
transition: transform 150ms ease;
}
.chat-json-collapse[open] > .chat-json-summary::before {
transform: rotate(90deg);
}
.chat-json-summary:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
}
.chat-json-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.4;
flex-shrink: 0;
}
.chat-json-label {
font-family: var(--mono);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-json-content {
margin: 0;
padding: 10px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
font-family: var(--mono);
font-size: 12px;
line-height: 1.5;
color: var(--text);
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.chat-json-content code {
font-family: inherit;
font-size: inherit;
}
.chat-tool-msg-collapse {
margin-top: 2px;
}
.chat-tool-msg-summary {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
cursor: pointer;
font-size: 12px;
color: var(--muted);
user-select: none;
list-style: none;
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
transition:
color 150ms ease,
background 150ms ease,
border-color 150ms ease;
}
.chat-tool-msg-summary::-webkit-details-marker {
display: none;
}
.chat-tool-msg-summary::before {
content: "▸";
font-size: 10px;
flex-shrink: 0;
transition: transform 150ms ease;
}
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary::before {
transform: rotate(90deg);
}
.chat-tool-msg-summary:hover {
color: var(--text);
background: color-mix(in srgb, var(--bg-hover) 60%, transparent);
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
}
.chat-tool-msg-summary__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
color: var(--accent);
opacity: 0.75;
flex-shrink: 0;
}
.chat-tool-msg-summary__icon svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.chat-tool-msg-summary__label {
font-weight: 600;
color: var(--text);
flex-shrink: 0;
}
.chat-tool-msg-summary__names {
font-family: var(--mono);
font-size: 11px;
opacity: 0.85;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.chat-tool-msg-summary__preview {
font-family: var(--mono);
font-size: 11px;
opacity: 0.85;
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.chat-tool-msg-body {
padding-top: 8px;
}
/* Reading Indicator */ /* Reading Indicator */
.chat-reading-indicator { .chat-reading-indicator {
background: transparent; background: transparent;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
--shell-pad: 16px; --shell-pad: 16px;
--shell-gap: 16px; --shell-gap: 16px;
--shell-nav-width: 220px; --shell-nav-width: 220px;
--shell-topbar-height: 56px; --shell-nav-rail-width: 72px;
--shell-topbar-height: 52px;
--shell-focus-duration: 200ms; --shell-focus-duration: 200ms;
--shell-focus-ease: var(--ease-out); --shell-focus-ease: var(--ease-out);
height: 100vh; height: 100vh;
@@ -17,7 +18,7 @@
"topbar topbar" "topbar topbar"
"nav content"; "nav content";
gap: 0; gap: 0;
animation: dashboard-enter 0.4s var(--ease-out); animation: dashboard-enter 0.3s var(--ease-out);
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease); transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
overflow: hidden; overflow: hidden;
} }
@@ -41,7 +42,7 @@
} }
.shell--nav-collapsed { .shell--nav-collapsed {
grid-template-columns: 0px minmax(0, 1fr); grid-template-columns: var(--shell-nav-rail-width) minmax(0, 1fr);
} }
.shell--chat-focus { .shell--chat-focus {
@@ -64,7 +65,7 @@
padding-top: 0; padding-top: 0;
} }
.shell--chat-focus .content > * + * { .shell--chat-focus .content>*+* {
margin-top: 0; margin-top: 0;
} }
@@ -84,7 +85,9 @@
padding: 0 20px; padding: 0 20px;
height: var(--shell-topbar-height); height: var(--shell-topbar-height);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg); background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: blur(12px) saturate(1.6);
-webkit-backdrop-filter: blur(12px) saturate(1.6);
} }
.topbar-left { .topbar-left {
@@ -113,12 +116,12 @@
.brand { .brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
.brand-logo { .brand-logo {
width: 28px; width: 26px;
height: 28px; height: 26px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -131,11 +134,11 @@
.brand-text { .brand-text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1px; gap: 0;
} }
.brand-title { .brand-title {
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.03em; letter-spacing: -0.03em;
line-height: 1.1; line-height: 1.1;
@@ -143,10 +146,10 @@
} }
.brand-sub { .brand-sub {
font-size: 10px; font-size: 9px;
font-weight: 500; font-weight: 500;
color: var(--muted); color: var(--muted);
letter-spacing: 0.05em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
line-height: 1; line-height: 1;
} }
@@ -179,93 +182,389 @@
height: 6px; height: 6px;
} }
.topbar-status .theme-toggle { .topbar-status .theme-orb__trigger {
--theme-item: 24px; width: 26px;
--theme-gap: 2px; height: 26px;
--theme-pad: 3px; font-size: 13px;
} }
.topbar-status .theme-icon { /* Topbar search trigger */
width: 12px; .topbar-search {
height: 12px; display: inline-flex;
align-items: center;
gap: 12px;
padding: 7px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
color: var(--muted);
font-size: 13px;
cursor: pointer;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
color var(--duration-fast) ease;
min-width: 180px;
}
.topbar-search:hover {
border-color: var(--border-strong);
background: var(--bg-hover);
color: var(--text);
}
.topbar-search:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.topbar-search__label {
flex: 1;
text-align: left;
}
.topbar-search__kbd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
font-family: var(--mono);
font-size: 11px;
line-height: 1;
color: var(--muted);
}
.topbar-theme-mode {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg-elevated) 70%, transparent);
}
.topbar-theme-mode__btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid transparent;
border-radius: calc(var(--radius-md) - 1px);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
color var(--duration-fast) ease,
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
}
.topbar-theme-mode__btn:hover {
color: var(--text);
background: var(--bg-hover);
}
.topbar-theme-mode__btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.topbar-theme-mode__btn--active {
color: var(--accent);
background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
.topbar-theme-mode__btn svg {
width: 14px;
height: 14px;
stroke: currentColor;
fill: none;
stroke-width: 1.75px;
stroke-linecap: round;
stroke-linejoin: round;
} }
/* =========================================== /* ===========================================
Navigation Sidebar Navigation Sidebar (shadcn-inspired)
=========================================== */ =========================================== */
.nav { /* Sidebar wrapper occupies the "nav" grid area */
.shell-nav {
grid-area: nav; grid-area: nav;
display: flex;
min-height: 0;
overflow: hidden;
transition: width var(--shell-focus-duration) var(--shell-focus-ease);
}
/* The sidebar panel itself */
.sidebar {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg);
}
:root[data-theme-mode="light"] .sidebar {
background: var(--panel);
}
/* Collapsed: icon-only rail */
.sidebar--collapsed {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
flex: 0 0 var(--shell-nav-rail-width);
border-right: 1px solid color-mix(in srgb, var(--border-strong) 72%, transparent);
}
/* Header: brand + collapse toggle */
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 14px 6px;
flex-shrink: 0;
}
.sidebar--collapsed .sidebar-header {
justify-content: center;
padding: 12px 10px 6px;
}
/* Brand lockup */
.sidebar-brand {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sidebar-brand__logo {
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 6px;
}
.sidebar-brand__title {
font-size: 14px;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--text-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Scrollable nav body */
.sidebar-nav {
flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 16px 12px; padding: 4px 8px;
background: var(--bg); scrollbar-width: none;
scrollbar-width: none; /* Firefox */
transition:
width var(--shell-focus-duration) var(--shell-focus-ease),
padding var(--shell-focus-duration) var(--shell-focus-ease),
opacity var(--shell-focus-duration) var(--shell-focus-ease);
min-height: 0;
} }
.nav::-webkit-scrollbar { .sidebar-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */ display: none;
} }
.shell--chat-focus .nav { .sidebar--collapsed .sidebar-nav {
width: 0; padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Collapsed sidebar: centre icons, hide text */
.sidebar--collapsed .nav-group__label {
display: none;
}
.sidebar--collapsed .nav-group {
gap: 4px;
margin-bottom: 0;
}
/* In collapsed sidebar, always show nav items (icon-only) regardless of group collapse state */
.sidebar--collapsed .nav-group--collapsed .nav-group__items {
display: grid;
}
.sidebar--collapsed .nav-item {
justify-content: center;
width: 44px;
height: 42px;
padding: 0; padding: 0;
border-width: 0; margin: 0 auto;
overflow: hidden; border-radius: 16px;
pointer-events: none;
opacity: 0;
} }
.nav--collapsed { .sidebar--collapsed .nav-item__icon {
width: 18px;
height: 18px;
opacity: 0.78;
}
.sidebar--collapsed .nav-item__icon svg {
width: 18px;
height: 18px;
}
.sidebar--collapsed .nav-item__text {
display: none;
}
.sidebar--collapsed .nav-item__external-icon {
display: none;
}
/* Footer: docs link + version */
.sidebar-footer {
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--border);
}
.sidebar--collapsed .sidebar-footer {
padding: 12px 8px 10px;
}
.sidebar-footer__docs-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sidebar--collapsed .sidebar-footer__docs-block {
align-items: center;
gap: 10px;
}
.sidebar--collapsed .sidebar-footer .nav-item {
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
}
.sidebar-version {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
}
.sidebar-version__text {
font-size: 11px;
color: var(--muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.sidebar-version__dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 78%, white 22%);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
opacity: 1;
margin: 0 auto;
}
/* Drag-to-resize handle */
.sidebar-resizer {
width: 3px;
cursor: col-resize;
flex-shrink: 0;
background: transparent;
transition: background var(--duration-fast) ease;
position: relative;
}
.sidebar-resizer::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: transparent;
transition: background var(--duration-fast) ease;
}
.sidebar-resizer:hover::after {
background: var(--accent);
opacity: 0.35;
}
.sidebar-resizer:active::after {
background: var(--accent);
opacity: 0.6;
}
/* Shell-level collapsed / focus overrides */
.shell--nav-collapsed .shell-nav {
width: var(--shell-nav-rail-width);
min-width: var(--shell-nav-rail-width);
}
.shell--chat-focus .shell-nav {
width: 0; width: 0;
min-width: 0; min-width: 0;
padding: 0;
overflow: hidden; overflow: hidden;
border: none;
opacity: 0;
pointer-events: none; pointer-events: none;
opacity: 0;
} }
/* Nav collapse toggle */ /* Nav collapse toggle */
.nav-collapse-toggle { .nav-collapse-toggle {
width: 32px; width: 28px;
height: 32px; height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-md); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
transition: transition:
background var(--duration-fast) ease, background var(--duration-fast) ease,
border-color var(--duration-fast) ease; border-color var(--duration-fast) ease,
margin-bottom: 16px; color var(--duration-fast) ease;
margin-bottom: 0;
color: var(--muted);
} }
.nav-collapse-toggle:hover { .nav-collapse-toggle:hover {
background: var(--bg-hover); background: var(--bg-hover);
border-color: var(--border); color: var(--text);
} }
.nav-collapse-toggle__icon { .nav-collapse-toggle__icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 18px; width: 16px;
height: 18px; height: 16px;
color: var(--muted); color: inherit;
transition: color var(--duration-fast) ease;
} }
.nav-collapse-toggle__icon svg { .nav-collapse-toggle__icon svg {
width: 18px; width: 16px;
height: 18px; height: 16px;
stroke: currentColor; stroke: currentColor;
fill: none; fill: none;
stroke-width: 1.5px; stroke-width: 1.5px;
@@ -274,14 +573,14 @@
} }
.nav-collapse-toggle:hover .nav-collapse-toggle__icon { .nav-collapse-toggle:hover .nav-collapse-toggle__icon {
color: var(--text); color: inherit;
} }
/* Nav groups */ /* Nav groups */
.nav-group { .nav-group {
margin-bottom: 20px; margin-bottom: 12px;
display: grid; display: grid;
gap: 2px; gap: 1px;
} }
.nav-group:last-child { .nav-group:last-child {
@@ -297,53 +596,67 @@
display: none; display: none;
} }
/* Nav label */ .nav-group__label {
.nav-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 6px 10px; padding: 5px 10px;
font-size: 11px; font-size: 10px;
font-weight: 500; font-weight: 600;
color: var(--muted); color: var(--muted);
margin-bottom: 4px; margin-bottom: 2px;
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
text-transform: uppercase;
letter-spacing: 0.06em;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: transition:
color var(--duration-fast) ease, color var(--duration-fast) ease,
background var(--duration-fast) ease; background var(--duration-fast) ease;
} }
.nav-label:hover { .nav-group__label:hover {
color: var(--text); color: var(--text);
background: var(--bg-hover); background: var(--bg-hover);
} }
.nav-label--static { .nav-group__label--static {
cursor: default; cursor: default;
} }
.nav-label--static:hover { .nav-group__label--static:hover {
color: var(--muted); color: var(--muted);
background: transparent; background: transparent;
} }
.nav-label__text { .nav-group__label-text {
flex: 1; flex: 1;
} }
.nav-label__chevron { .nav-group__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px; font-size: 10px;
opacity: 0.5; opacity: 0.5;
transition: transform var(--duration-fast) ease; transition: transform var(--duration-fast) ease;
} }
.nav-group--collapsed .nav-label__chevron { .nav-group__chevron svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-group--collapsed .nav-group__chevron {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
@@ -353,8 +666,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 8px;
padding: 8px 10px; padding: 7px 10px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid transparent; border: 1px solid transparent;
background: transparent; background: transparent;
@@ -368,19 +681,19 @@
} }
.nav-item__icon { .nav-item__icon {
width: 16px; width: 15px;
height: 16px; height: 15px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
opacity: 0.7; opacity: 0.6;
transition: opacity var(--duration-fast) ease; transition: opacity var(--duration-fast) ease;
} }
.nav-item__icon svg { .nav-item__icon svg {
width: 16px; width: 15px;
height: 16px; height: 15px;
stroke: currentColor; stroke: currentColor;
fill: none; fill: none;
stroke-width: 1.5px; stroke-width: 1.5px;
@@ -390,7 +703,7 @@
.nav-item__text { .nav-item__text {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 450;
white-space: nowrap; white-space: nowrap;
} }
@@ -401,37 +714,102 @@
} }
.nav-item:hover .nav-item__icon { .nav-item:hover .nav-item__icon {
opacity: 1; opacity: 0.9;
} }
.nav-item.active { .nav-item.active,
.nav-item--active {
color: var(--text-strong); color: var(--text-strong);
background: var(--accent-subtle); background: var(--accent-subtle);
border-color: color-mix(in srgb, var(--accent) 15%, transparent);
} }
.nav-item.active .nav-item__icon { .nav-item.active .nav-item__icon,
.nav-item--active .nav-item__icon {
opacity: 1; opacity: 1;
color: var(--accent); color: var(--accent);
} }
.sidebar--collapsed .nav-item--active::before,
.sidebar--collapsed .nav-item.active::before {
content: "";
position: absolute;
left: 6px;
top: 11px;
bottom: 11px;
width: 2px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 78%, transparent);
}
.sidebar--collapsed .nav-item.active,
.sidebar--collapsed .nav-item--active {
background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%);
border-color: color-mix(in srgb, var(--accent) 12%, var(--border) 88%);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent);
}
.sidebar--collapsed .nav-collapse-toggle {
width: 44px;
height: 34px;
margin-bottom: 0;
border-color: color-mix(in srgb, var(--border-strong) 74%, transparent);
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent),
0 8px 18px color-mix(in srgb, black 16%, transparent);
}
.sidebar--collapsed .nav-collapse-toggle:hover {
border-color: color-mix(in srgb, var(--border-strong) 72%, transparent);
background: color-mix(in srgb, var(--bg-elevated) 96%, transparent);
}
.nav-item__external-icon {
width: 12px;
height: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: auto;
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.nav-item__external-icon svg {
width: 12px;
height: 12px;
stroke: currentColor;
fill: none;
stroke-width: 1.5px;
stroke-linecap: round;
stroke-linejoin: round;
}
.nav-item:hover .nav-item__external-icon {
opacity: 0.5;
}
/* =========================================== /* ===========================================
Content Area Content Area
=========================================== */ =========================================== */
.content { .content {
grid-area: content; grid-area: content;
padding: 12px 16px 32px; padding: 16px 20px 32px;
display: block; display: block;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.content > * + * { .content>*+* {
margin-top: 24px; margin-top: 20px;
} }
:root[data-theme="light"] .content { :root[data-theme-mode="light"] .content {
background: var(--bg-content); background: var(--bg-content);
} }
@@ -443,7 +821,7 @@
padding-bottom: 0; padding-bottom: 0;
} }
.content--chat > * + * { .content--chat>*+* {
margin-top: 0; margin-top: 0;
} }
@@ -473,19 +851,19 @@
} }
.page-title { .page-title {
font-size: 26px; font-size: 22px;
font-weight: 700; font-weight: 650;
letter-spacing: -0.035em; letter-spacing: -0.03em;
line-height: 1.15; line-height: 1.2;
color: var(--text-strong); color: var(--text-strong);
} }
.page-sub { .page-sub {
color: var(--muted); color: var(--muted);
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 400;
margin-top: 6px; margin-top: 4px;
letter-spacing: -0.01em; letter-spacing: -0.005em;
} }
.page-meta { .page-meta {
@@ -501,7 +879,7 @@
gap: 16px; gap: 16px;
} }
.content--chat .content-header > div:first-child { .content--chat .content-header>div:first-child {
text-align: left; text-align: left;
} }
@@ -577,18 +955,6 @@
"content"; "content";
} }
.nav {
position: static;
max-height: none;
display: flex;
gap: 6px;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--border);
padding: 10px 14px;
background: var(--bg);
}
.nav-group { .nav-group {
grid-auto-flow: column; grid-auto-flow: column;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));

View File

@@ -2,45 +2,102 @@
Mobile Layout Mobile Layout
=========================================== */ =========================================== */
/* Tablet: Horizontal nav */ /* Tablet and smaller: collapse the left nav into a horizontal rail. */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.nav { .shell,
.shell--nav-collapsed {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--shell-topbar-height) auto minmax(0, 1fr);
grid-template-areas:
"topbar"
"nav"
"content";
}
.shell--chat-focus {
grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr);
}
.shell-nav,
.shell--nav-collapsed .shell-nav {
width: auto;
min-width: 0;
border-bottom: 1px solid var(--border);
}
.sidebar,
.sidebar--collapsed {
width: auto;
min-width: 0;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
border-right: none;
}
.sidebar-header,
.sidebar--collapsed .sidebar-header {
justify-content: flex-start;
padding: 8px 10px;
flex: 0 0 auto;
}
.sidebar-brand {
display: none;
}
.sidebar-nav,
.sidebar--collapsed .sidebar-nav {
flex: 1 1 auto;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
gap: 4px; gap: 8px;
padding: 10px 14px; padding: 8px 10px 8px 0;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
} }
.nav::-webkit-scrollbar { .sidebar-nav::-webkit-scrollbar,
.sidebar--collapsed .sidebar-nav::-webkit-scrollbar {
display: none; display: none;
} }
.nav-group,
.nav-group__items,
.sidebar--collapsed .nav-group,
.sidebar--collapsed .nav-group__items {
display: contents;
}
.nav-group { .nav-group {
display: contents; margin-bottom: 0;
} }
.nav-group__items { .sidebar-nav .nav-group__label {
display: contents;
}
.nav-label {
display: none; display: none;
} }
.nav-group--collapsed .nav-group__items { .nav-item,
display: contents; .sidebar--collapsed .nav-item {
} margin: 0;
.nav-item {
padding: 8px 14px; padding: 8px 14px;
font-size: 13px; font-size: 13px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex: 0 0 auto;
}
.sidebar--collapsed .nav-item--active::before,
.sidebar--collapsed .nav-item.active::before {
content: none;
}
.sidebar-footer,
.sidebar--collapsed .sidebar-footer {
display: none;
} }
} }
@@ -94,24 +151,17 @@
display: none; display: none;
} }
/* Nav */ .shell-nav {
.nav { border-bottom-width: 0;
padding: 8px 10px;
gap: 4px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
} }
.nav::-webkit-scrollbar { .sidebar-header {
display: none; padding: 6px 8px;
} }
.nav-group { .sidebar-nav {
display: contents; gap: 6px;
} padding: 6px 8px 6px 0;
.nav-label {
display: none;
} }
.nav-item { .nav-item {
@@ -239,6 +289,26 @@
font-size: 14px; font-size: 14px;
} }
.agent-chat__input {
margin: 0 8px 10px;
}
.agent-chat__toolbar {
padding: 4px 8px;
}
.agent-chat__input-btn,
.agent-chat__toolbar .btn-ghost {
width: 28px;
height: 28px;
}
.agent-chat__input-btn svg,
.agent-chat__toolbar .btn-ghost svg {
width: 14px;
height: 14px;
}
/* Log stream */ /* Log stream */
.log-stream { .log-stream {
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -288,16 +358,10 @@
font-size: 11px; font-size: 11px;
} }
/* Theme toggle */ .theme-orb__trigger {
.theme-toggle { width: 26px;
--theme-item: 24px; height: 26px;
--theme-gap: 2px; font-size: 13px;
--theme-pad: 3px;
}
.theme-icon {
width: 12px;
height: 12px;
} }
} }
@@ -315,10 +379,6 @@
font-size: 13px; font-size: 13px;
} }
.nav {
padding: 6px 8px;
}
.nav-item { .nav-item {
padding: 6px 8px; padding: 6px 8px;
font-size: 11px; font-size: 11px;
@@ -361,14 +421,9 @@
font-size: 10px; font-size: 10px;
} }
.theme-toggle { .theme-orb__trigger {
--theme-item: 22px; width: 24px;
--theme-gap: 2px; height: 24px;
--theme-pad: 2px; font-size: 12px;
}
.theme-icon {
width: 11px;
height: 11px;
} }
} }

View File

@@ -3,25 +3,33 @@ import { scheduleChatScroll } from "./app-scroll.ts";
import { setLastActiveSessionKey } from "./app-settings.ts"; import { setLastActiveSessionKey } from "./app-settings.ts";
import { resetToolStream } from "./app-tool-stream.ts"; import { resetToolStream } from "./app-tool-stream.ts";
import type { OpenClawApp } from "./app.ts"; import type { OpenClawApp } from "./app.ts";
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
import { parseSlashCommand } from "./chat/slash-commands.ts";
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts"; import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import type { GatewayHelloOk } from "./gateway.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import { normalizeBasePath } from "./navigation.ts"; import { normalizeBasePath } from "./navigation.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts"; import { generateUUID } from "./uuid.ts";
export type ChatHost = { export type ChatHost = {
client: GatewayBrowserClient | null;
chatMessages: unknown[];
chatStream: string | null;
connected: boolean; connected: boolean;
chatMessage: string; chatMessage: string;
chatAttachments: ChatAttachment[]; chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[]; chatQueue: ChatQueueItem[];
chatRunId: string | null; chatRunId: string | null;
chatSending: boolean; chatSending: boolean;
lastError?: string | null;
sessionKey: string; sessionKey: string;
basePath: string; basePath: string;
hello: GatewayHelloOk | null; hello: GatewayHelloOk | null;
chatAvatarUrl: string | null; chatAvatarUrl: string | null;
refreshSessionsAfterChat: Set<string>; refreshSessionsAfterChat: Set<string>;
/** Callback for slash-command side effects that need app-level access. */
onSlashAction?: (action: string) => void;
}; };
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120; export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
@@ -73,6 +81,7 @@ function enqueueChatMessage(
text: string, text: string,
attachments?: ChatAttachment[], attachments?: ChatAttachment[],
refreshSessions?: boolean, refreshSessions?: boolean,
localCommand?: { args: string; name: string },
) { ) {
const trimmed = text.trim(); const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0); const hasAttachments = Boolean(attachments && attachments.length > 0);
@@ -87,6 +96,8 @@ function enqueueChatMessage(
createdAt: Date.now(), createdAt: Date.now(),
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined, attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
refreshSessions, refreshSessions,
localCommandArgs: localCommand?.args,
localCommandName: localCommand?.name,
}, },
]; ];
} }
@@ -143,12 +154,25 @@ async function flushChatQueue(host: ChatHost) {
return; return;
} }
host.chatQueue = rest; host.chatQueue = rest;
const ok = await sendChatMessageNow(host, next.text, { let ok = false;
attachments: next.attachments, try {
refreshSessions: next.refreshSessions, if (next.localCommandName) {
}); await dispatchSlashCommand(host, next.localCommandName, next.localCommandArgs ?? "");
ok = true;
} else {
ok = await sendChatMessageNow(host, next.text, {
attachments: next.attachments,
refreshSessions: next.refreshSessions,
});
}
} catch (err) {
host.lastError = String(err);
}
if (!ok) { if (!ok) {
host.chatQueue = [next, ...host.chatQueue]; host.chatQueue = [next, ...host.chatQueue];
} else if (host.chatQueue.length > 0) {
// Continue draining — local commands don't block on server response
void flushChatQueue(host);
} }
} }
@@ -170,7 +194,6 @@ export async function handleSendChat(
const attachmentsToSend = messageOverride == null ? attachments : []; const attachmentsToSend = messageOverride == null ? attachments : [];
const hasAttachments = attachmentsToSend.length > 0; const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) { if (!message && !hasAttachments) {
return; return;
} }
@@ -180,10 +203,35 @@ export async function handleSendChat(
return; return;
} }
// Intercept local slash commands (/status, /model, /compact, etc.)
const parsed = parseSlashCommand(message);
if (parsed?.command.executeLocal) {
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) {
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
}
enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
args: parsed.args,
name: parsed.command.name,
});
return;
}
const prevDraft = messageOverride == null ? previousDraft : undefined;
if (messageOverride == null) {
host.chatMessage = "";
host.chatAttachments = [];
}
await dispatchSlashCommand(host, parsed.command.name, parsed.args, {
previousDraft: prevDraft,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
});
return;
}
const refreshSessions = isChatResetCommand(message); const refreshSessions = isChatResetCommand(message);
if (messageOverride == null) { if (messageOverride == null) {
host.chatMessage = ""; host.chatMessage = "";
// Clear attachments when sending
host.chatAttachments = []; host.chatAttachments = [];
} }
@@ -202,11 +250,99 @@ export async function handleSendChat(
}); });
} }
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export"].includes(name);
}
// ── Slash Command Dispatch ──
async function dispatchSlashCommand(
host: ChatHost,
name: string,
args: string,
sendOpts?: { previousDraft?: string; restoreDraft?: boolean },
) {
switch (name) {
case "stop":
await handleAbortChat(host);
return;
case "new":
await sendChatMessageNow(host, "/new", {
refreshSessions: true,
previousDraft: sendOpts?.previousDraft,
restoreDraft: sendOpts?.restoreDraft,
});
return;
case "reset":
await sendChatMessageNow(host, "/reset", {
refreshSessions: true,
previousDraft: sendOpts?.previousDraft,
restoreDraft: sendOpts?.restoreDraft,
});
return;
case "clear":
await clearChatHistory(host);
return;
case "focus":
host.onSlashAction?.("toggle-focus");
return;
case "export":
host.onSlashAction?.("export");
return;
}
if (!host.client) {
return;
}
const result = await executeSlashCommand(host.client, host.sessionKey, name, args);
if (result.content) {
injectCommandResult(host, result.content);
}
if (result.action === "refresh") {
await refreshChat(host);
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
async function clearChatHistory(host: ChatHost) {
if (!host.client || !host.connected) {
return;
}
try {
await host.client.request("sessions.reset", { key: host.sessionKey });
host.chatMessages = [];
host.chatStream = null;
host.chatRunId = null;
await loadChatHistory(host as unknown as OpenClawApp);
} catch (err) {
host.lastError = String(err);
}
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
}
function injectCommandResult(host: ChatHost, content: string) {
host.chatMessages = [
...host.chatMessages,
{
role: "system",
content,
timestamp: Date.now(),
},
];
}
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) { export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
await Promise.all([ await Promise.all([
loadChatHistory(host as unknown as OpenClawApp), loadChatHistory(host as unknown as OpenClawApp),
loadSessions(host as unknown as OpenClawApp, { loadSessions(host as unknown as OpenClawApp, {
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES, activeMinutes: 0,
limit: 0,
includeGlobal: false,
includeUnknown: false,
}), }),
refreshChatAvatar(host), refreshChatAvatar(host),
]); ]);

View File

@@ -14,7 +14,7 @@ import {
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts"; import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
import type { OpenClawApp } from "./app.ts"; import type { OpenClawApp } from "./app.ts";
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts"; import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; import { loadAgents } from "./controllers/agents.ts";
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts"; import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
import { loadChatHistory } from "./controllers/chat.ts"; import { loadChatHistory } from "./controllers/chat.ts";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
@@ -26,6 +26,7 @@ import {
parseExecApprovalResolved, parseExecApprovalResolved,
removeExecApproval, removeExecApproval,
} from "./controllers/exec-approval.ts"; } from "./controllers/exec-approval.ts";
import { loadHealthState } from "./controllers/health.ts";
import { loadNodes } from "./controllers/nodes.ts"; import { loadNodes } from "./controllers/nodes.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import { import {
@@ -39,7 +40,7 @@ import type { UiSettings } from "./storage.ts";
import type { import type {
AgentsListResult, AgentsListResult,
PresenceEntry, PresenceEntry,
HealthSnapshot, HealthSummary,
StatusSummary, StatusSummary,
UpdateAvailable, UpdateAvailable,
} from "./types.ts"; } from "./types.ts";
@@ -81,10 +82,10 @@ type GatewayHost = {
agentsLoading: boolean; agentsLoading: boolean;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsError: string | null; agentsError: string | null;
toolsCatalogLoading: boolean; healthLoading: boolean;
toolsCatalogError: string | null; healthResult: HealthSummary | null;
toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null; healthError: string | null;
debugHealth: HealthSnapshot | null; debugHealth: HealthSummary | null;
assistantName: string; assistantName: string;
assistantAvatar: string | null; assistantAvatar: string | null;
assistantAgentId: string | null; assistantAgentId: string | null;
@@ -221,7 +222,7 @@ export function connectGateway(host: GatewayHost) {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]); resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as OpenClawApp); void loadAssistantIdentity(host as unknown as OpenClawApp);
void loadAgents(host as unknown as OpenClawApp); void loadAgents(host as unknown as OpenClawApp);
void loadToolsCatalog(host as unknown as OpenClawApp); void loadHealthState(host as unknown as OpenClawApp);
void loadNodes(host as unknown as OpenClawApp, { quiet: true }); void loadNodes(host as unknown as OpenClawApp, { quiet: true });
void loadDevices(host as unknown as OpenClawApp, { quiet: true }); void loadDevices(host as unknown as OpenClawApp, { quiet: true });
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]); void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
@@ -326,7 +327,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
{ ts: Date.now(), event: evt.event, payload: evt.payload }, { ts: Date.now(), event: evt.event, payload: evt.payload },
...host.eventLogBuffer, ...host.eventLogBuffer,
].slice(0, 250); ].slice(0, 250);
if (host.tab === "debug") { if (host.tab === "debug" || host.tab === "overview") {
host.eventLog = host.eventLogBuffer; host.eventLog = host.eventLogBuffer;
} }
@@ -406,7 +407,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
const snapshot = hello.snapshot as const snapshot = hello.snapshot as
| { | {
presence?: PresenceEntry[]; presence?: PresenceEntry[];
health?: HealthSnapshot; health?: HealthSummary;
sessionDefaults?: SessionDefaultsSnapshot; sessionDefaults?: SessionDefaultsSnapshot;
updateAvailable?: UpdateAvailable; updateAvailable?: UpdateAvailable;
} }
@@ -416,6 +417,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
} }
if (snapshot?.health) { if (snapshot?.health) {
host.debugHealth = snapshot.health; host.debugHealth = snapshot.health;
host.healthResult = snapshot.health;
} }
if (snapshot?.sessionDefaults) { if (snapshot?.sessionDefaults) {
applySessionDefaults(host, snapshot.sessionDefaults); applySessionDefaults(host, snapshot.sessionDefaults);

View File

@@ -1,15 +1,17 @@
import { html } from "lit"; import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js"; import { repeat } from "lit/directives/repeat.js";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { refreshChat } from "./app-chat.ts"; import { refreshChat } from "./app-chat.ts";
import { syncUrlWithSessionKey } from "./app-settings.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts";
import type { AppViewState } from "./app-view-state.ts"; import type { AppViewState } from "./app-view-state.ts";
import { OpenClawApp } from "./app.ts"; import { OpenClawApp } from "./app.ts";
import { ChatState, loadChatHistory } from "./controllers/chat.ts"; import { ChatState, loadChatHistory } from "./controllers/chat.ts";
import { loadSessions } from "./controllers/sessions.ts";
import { icons } from "./icons.ts"; import { icons } from "./icons.ts";
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts"; import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
import type { ThemeTransitionContext } from "./theme-transition.ts"; import type { ThemeTransitionContext } from "./theme-transition.ts";
import type { ThemeMode } from "./theme.ts"; import type { ThemeMode, ThemeName } from "./theme.ts";
import type { SessionsListResult } from "./types.ts"; import type { SessionsListResult } from "./types.ts";
type SessionDefaultsSnapshot = { type SessionDefaultsSnapshot = {
@@ -49,10 +51,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
export function renderTab(state: AppViewState, tab: Tab) { export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath); const href = pathForTab(tab, state.basePath);
const isActive = state.tab === tab;
const collapsed = state.settings.navCollapsed;
return html` return html`
<a <a
href=${href} href=${href}
class="nav-item ${state.tab === tab ? "active" : ""}" class="nav-item ${isActive ? "nav-item--active" : ""}"
@click=${(event: MouseEvent) => { @click=${(event: MouseEvent) => {
if ( if (
event.defaultPrevented || event.defaultPrevented ||
@@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
title=${titleForTab(tab)} title=${titleForTab(tab)}
> >
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span> <span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
<span class="nav-item__text">${titleForTab(tab)}</span> ${!collapsed ? html`<span class="nav-item__text">${titleForTab(tab)}</span>` : nothing}
</a> </a>
`; `;
} }
@@ -122,23 +126,52 @@ function renderCronFilterIcon(hiddenCount: number) {
`; `;
} }
export function renderChatSessionSelect(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
return html`
<div class="chat-controls__session-row">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected || sessionGroups.length === 0}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
if (state.sessionKey === next) {
return;
}
switchChatSession(state, next);
}}
>
${repeat(
sessionGroups,
(group) => group.id,
(group) =>
html`<optgroup label=${group.label}>
${repeat(
group.options,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key} title=${entry.title}>
${entry.label}
</option>`,
)}
</optgroup>`,
)}
</select>
</label>
</div>
`;
}
export function renderChatControls(state: AppViewState) { export function renderChatControls(state: AppViewState) {
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
const hideCron = state.sessionsHideCron ?? true; const hideCron = state.sessionsHideCron ?? true;
const hiddenCronCount = hideCron const hiddenCronCount = hideCron
? countHiddenCronSessions(state.sessionKey, state.sessionsResult) ? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
: 0; : 0;
const sessionOptions = resolveSessionOptions(
state.sessionKey,
state.sessionsResult,
mainSessionKey,
hideCron,
);
const disableThinkingToggle = state.onboarding; const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding; const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode; const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html` const refreshIcon = html`
<svg <svg
width="18" width="18"
@@ -174,43 +207,6 @@ export function renderChatControls(state: AppViewState) {
`; `;
return html` return html`
<div class="chat-controls"> <div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
?disabled=${!state.connected}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
state.sessionKey = next;
state.chatMessage = "";
state.chatStream = null;
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
state.chatRunId = null;
(state as unknown as OpenClawApp).resetToolStream();
(state as unknown as OpenClawApp).resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
next,
true,
);
void loadChatHistory(state as unknown as ChatState);
}}
>
${repeat(
sessionOptions,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key} title=${entry.key}>
${entry.displayName ?? entry.key}
</option>`,
)}
</select>
</label>
<button <button
class="btn btn--sm btn--icon" class="btn btn--sm btn--icon"
?disabled=${state.chatLoading || !state.connected} ?disabled=${state.chatLoading || !state.connected}
@@ -291,23 +287,38 @@ export function renderChatControls(state: AppViewState) {
`; `;
} }
function resolveMainSessionKey( function switchChatSession(state: AppViewState, nextSessionKey: string) {
hello: AppViewState["hello"], state.sessionKey = nextSessionKey;
sessions: SessionsListResult | null, state.chatMessage = "";
): string | null { state.chatStream = null;
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; // P1: Clear queued chat items from the previous session
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim(); (state as unknown as { chatQueue: unknown[] }).chatQueue = [];
if (mainSessionKey) { (state as unknown as OpenClawApp).chatStreamStartedAt = null;
return mainSessionKey; state.chatRunId = null;
} (state as unknown as OpenClawApp).resetToolStream();
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim(); (state as unknown as OpenClawApp).resetChatScroll();
if (mainKey) { state.applySettings({
return mainKey; ...state.settings,
} sessionKey: nextSessionKey,
if (sessions?.sessions?.some((row) => row.key === "main")) { lastActiveSessionKey: nextSessionKey,
return "main"; });
} void state.loadAssistantIdentity();
return null; syncUrlWithSessionKey(
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
nextSessionKey,
true,
);
void loadChatHistory(state as unknown as ChatState);
void refreshSessionOptions(state);
}
async function refreshSessionOptions(state: AppViewState) {
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
activeMinutes: 0,
limit: 0,
includeGlobal: false,
includeUnknown: false,
});
} }
/* ── Channel display labels ────────────────────────────── */ /* ── Channel display labels ────────────────────────────── */
@@ -431,51 +442,75 @@ export function isCronSessionKey(key: string): boolean {
return rest.startsWith("cron:"); return rest.startsWith("cron:");
} }
function resolveSessionOptions( type SessionOptionEntry = {
key: string;
label: string;
title: string;
};
type SessionOptionGroup = {
id: string;
label: string;
options: SessionOptionEntry[];
};
export function resolveSessionOptionGroups(
state: AppViewState,
sessionKey: string, sessionKey: string,
sessions: SessionsListResult | null, sessions: SessionsListResult | null,
mainSessionKey?: string | null, ): SessionOptionGroup[] {
hideCron = false, const rows = sessions?.sessions ?? [];
) { const hideCron = state.sessionsHideCron ?? true;
const seen = new Set<string>(); const byKey = new Map<string, SessionsListResult["sessions"][number]>();
const options: Array<{ key: string; displayName?: string }> = []; for (const row of rows) {
byKey.set(row.key, row);
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add main session key first
if (mainSessionKey) {
seen.add(mainSessionKey);
options.push({
key: mainSessionKey,
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
});
} }
// Add current session key next — always include it even if it's a cron session, const seenKeys = new Set<string>();
// so the active session is never silently dropped from the select. const groups = new Map<string, SessionOptionGroup>();
if (!seen.has(sessionKey)) { const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
seen.add(sessionKey); const existing = groups.get(groupId);
options.push({ if (existing) {
key: sessionKey, return existing;
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
});
}
// Add sessions from the result, optionally filtering out cron sessions.
if (sessions?.sessions) {
for (const s of sessions.sessions) {
if (!seen.has(s.key) && !(hideCron && isCronSessionKey(s.key))) {
seen.add(s.key);
options.push({
key: s.key,
displayName: resolveSessionDisplayName(s.key, s),
});
}
} }
} const created: SessionOptionGroup = {
id: groupId,
label,
options: [],
};
groups.set(groupId, created);
return created;
};
return options; const addOption = (key: string) => {
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
const row = byKey.get(key);
const parsed = parseAgentSessionKey(key);
const group = parsed
? ensureGroup(
`agent:${parsed.agentId.toLowerCase()}`,
resolveAgentGroupLabel(state, parsed.agentId),
)
: ensureGroup("other", "Other Sessions");
const label = resolveSessionScopedOptionLabel(key, row, parsed?.rest);
group.options.push({
key,
label,
title: key,
});
};
for (const row of rows) {
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
continue;
}
addOption(row.key);
}
addOption(sessionKey);
return Array.from(groups.values());
} }
/** Count sessions with a cron: key that would be hidden when hideCron=true. */ /** Count sessions with a cron: key that would be hidden when hideCron=true. */
@@ -487,88 +522,162 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length; return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
} }
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
const normalized = agentIdRaw.trim().toLowerCase();
const agent = (state.agentsList?.agents ?? []).find(
(entry) => entry.id.trim().toLowerCase() === normalized,
);
const name = agent?.identity?.name?.trim() || agent?.name?.trim() || "";
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
}
export function renderThemeToggle(state: AppViewState) { function resolveSessionScopedOptionLabel(
const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode)); key: string,
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => { row?: SessionsListResult["sessions"][number],
const element = event.currentTarget as HTMLElement; rest?: string,
const context: ThemeTransitionContext = { element }; ) {
if (event.clientX || event.clientY) { const base = rest?.trim() || key;
context.pointerClientX = event.clientX; if (!row) {
context.pointerClientY = event.clientY; return base;
}
const displayName =
typeof row.displayName === "string" && row.displayName.trim().length > 0
? row.displayName.trim()
: null;
const label = typeof row.label === "string" ? row.label.trim() : "";
const showDisplayName = Boolean(
displayName && displayName !== key && displayName !== label && displayName !== base,
);
if (!showDisplayName) {
return base;
}
return `${base} · ${displayName}`;
}
type ThemeOption = { id: ThemeName; label: string; icon: string };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw", icon: "🦀" },
{ id: "knot", label: "Knot", icon: "🪢" },
{ id: "dash", label: "Dash", icon: "📊" },
];
type ThemeModeOption = { id: ThemeMode; label: string; short: string };
const THEME_MODE_OPTIONS: ThemeModeOption[] = [
{ id: "system", label: "System", short: "SYS" },
{ id: "light", label: "Light", short: "LIGHT" },
{ id: "dark", label: "Dark", short: "DARK" },
];
function currentThemeIcon(theme: ThemeName): string {
return THEME_OPTIONS.find((o) => o.id === theme)?.icon ?? "🎨";
}
export function renderTopbarThemeModeToggle(state: AppViewState) {
const modeIcon = (mode: ThemeMode) => {
if (mode === "system") {
return icons.monitor;
} }
state.setThemeMode(next, context); if (mode === "light") {
return icons.sun;
}
return icons.moon;
};
const applyMode = (mode: ThemeMode, e: Event) => {
if (mode === state.themeMode) {
return;
}
state.setThemeMode(mode, { element: e.currentTarget as HTMLElement });
}; };
return html` return html`
<div class="theme-toggle" style="--theme-index: ${index};"> <div class="topbar-theme-mode" role="group" aria-label="Color mode">
<div class="theme-toggle__track" role="group" aria-label="Theme"> ${THEME_MODE_OPTIONS.map(
<span class="theme-toggle__indicator"></span> (opt) => html`
<button <button
class="theme-toggle__button ${state.themeMode === "system" ? "active" : ""}" type="button"
@click=${applyTheme("system")} class="topbar-theme-mode__btn ${opt.id === state.themeMode ? "topbar-theme-mode__btn--active" : ""}"
aria-pressed=${state.themeMode === "system"} title=${opt.label}
aria-label="System theme" aria-label="Color mode: ${opt.label}"
title="System" aria-pressed=${opt.id === state.themeMode}
> @click=${(e: Event) => applyMode(opt.id, e)}
${renderMonitorIcon()} >
</button> ${modeIcon(opt.id)}
<button </button>
class="theme-toggle__button ${state.themeMode === "light" ? "active" : ""}" `,
@click=${applyTheme("light")} )}
aria-pressed=${state.themeMode === "light"}
aria-label="Light theme"
title="Light"
>
${renderSunIcon()}
</button>
<button
class="theme-toggle__button ${state.themeMode === "dark" ? "active" : ""}"
@click=${applyTheme("dark")}
aria-pressed=${state.themeMode === "dark"}
aria-label="Dark theme"
title="Dark"
>
${renderMoonIcon()}
</button>
</div>
</div> </div>
`; `;
} }
function renderSunIcon() { export function renderThemeToggle(state: AppViewState) {
return html` const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true"> orb.classList.toggle("theme-orb--open", nextOpen);
<circle cx="12" cy="12" r="4"></circle> const trigger = orb.querySelector<HTMLButtonElement>(".theme-orb__trigger");
<path d="M12 2v2"></path> const menu = orb.querySelector<HTMLElement>(".theme-orb__menu");
<path d="M12 20v2"></path> if (trigger) {
<path d="m4.93 4.93 1.41 1.41"></path> trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false");
<path d="m17.66 17.66 1.41 1.41"></path> }
<path d="M2 12h2"></path> if (menu) {
<path d="M20 12h2"></path> menu.setAttribute("aria-hidden", nextOpen ? "false" : "true");
<path d="m6.34 17.66-1.41 1.41"></path> }
<path d="m19.07 4.93-1.41 1.41"></path> };
</svg>
`;
}
function renderMoonIcon() { const toggleOpen = (e: Event) => {
return html` const orb = (e.currentTarget as HTMLElement).closest<HTMLElement>(".theme-orb");
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true"> if (!orb) {
<path return;
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" }
></path> const isOpen = orb.classList.contains("theme-orb--open");
</svg> if (isOpen) {
`; setOpen(orb, false);
} } else {
setOpen(orb, true);
const close = (ev: MouseEvent) => {
if (!orb.contains(ev.target as Node)) {
setOpen(orb, false);
document.removeEventListener("click", close);
}
};
requestAnimationFrame(() => document.addEventListener("click", close));
}
};
const pick = (opt: ThemeOption, e: Event) => {
const orb = (e.currentTarget as HTMLElement).closest<HTMLElement>(".theme-orb");
if (orb) {
setOpen(orb, false);
}
if (opt.id !== state.theme) {
const context: ThemeTransitionContext = { element: orb ?? undefined };
state.setTheme(opt.id, context);
}
};
function renderMonitorIcon() {
return html` return html`
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true"> <div class="theme-orb" aria-label="Theme">
<rect width="20" height="14" x="2" y="3" rx="2"></rect> <button
<line x1="8" x2="16" y1="21" y2="21"></line> type="button"
<line x1="12" x2="12" y1="17" y2="21"></line> class="theme-orb__trigger"
</svg> title="Theme"
aria-haspopup="menu"
aria-expanded="false"
@click=${toggleOpen}
>${currentThemeIcon(state.theme)}</button>
<div class="theme-orb__menu" role="menu" aria-hidden="true">
${THEME_OPTIONS.map(
(opt) => html`
<button
type="button"
class="theme-orb__option ${opt.id === state.theme ? "theme-orb__option--active" : ""}"
title=${opt.label}
role="menuitemradio"
aria-checked=${opt.id === state.theme}
aria-label=${opt.label}
@click=${(e: Event) => pick(opt, e)}
>${opt.icon}</button>`,
)}
</div>
</div>
`; `;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
import { refreshChat } from "./app-chat.ts"; import { refreshChat } from "./app-chat.ts";
import { import {
startLogsPolling, startLogsPolling,
@@ -9,15 +10,10 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
import type { OpenClawApp } from "./app.ts"; import type { OpenClawApp } from "./app.ts";
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts"; import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
import { loadAgentSkills } from "./controllers/agent-skills.ts"; import { loadAgentSkills } from "./controllers/agent-skills.ts";
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts"; import { loadAgents } from "./controllers/agents.ts";
import { loadChannels } from "./controllers/channels.ts"; import { loadChannels } from "./controllers/channels.ts";
import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
import { import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
loadCronJobs,
loadCronModelSuggestions,
loadCronRuns,
loadCronStatus,
} from "./controllers/cron.ts";
import { loadDebug } from "./controllers/debug.ts"; import { loadDebug } from "./controllers/debug.ts";
import { loadDevices } from "./controllers/devices.ts"; import { loadDevices } from "./controllers/devices.ts";
import { loadExecApprovals } from "./controllers/exec-approvals.ts"; import { loadExecApprovals } from "./controllers/exec-approvals.ts";
@@ -26,6 +22,7 @@ import { loadNodes } from "./controllers/nodes.ts";
import { loadPresence } from "./controllers/presence.ts"; import { loadPresence } from "./controllers/presence.ts";
import { loadSessions } from "./controllers/sessions.ts"; import { loadSessions } from "./controllers/sessions.ts";
import { loadSkills } from "./controllers/skills.ts"; import { loadSkills } from "./controllers/skills.ts";
import { loadUsage } from "./controllers/usage.ts";
import { import {
inferBasePathFromPathname, inferBasePathFromPathname,
normalizeBasePath, normalizeBasePath,
@@ -36,15 +33,9 @@ import {
} from "./navigation.ts"; } from "./navigation.ts";
import { saveSettings, type UiSettings } from "./storage.ts"; import { saveSettings, type UiSettings } from "./storage.ts";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
import { import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
colorSchemeForTheme, import type { AgentsListResult, AttentionItem } from "./types.ts";
dataThemeForTheme, import { resetChatViewState } from "./views/chat.ts";
resolveTheme,
type ResolvedTheme,
type ThemeMode,
type ThemeName,
} from "./theme.ts";
import type { AgentsListResult } from "./types.ts";
type SettingsHost = { type SettingsHost = {
settings: UiSettings; settings: UiSettings;
@@ -64,9 +55,8 @@ type SettingsHost = {
agentsList?: AgentsListResult | null; agentsList?: AgentsListResult | null;
agentsSelectedId?: string | null; agentsSelectedId?: string | null;
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron"; agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
pendingGatewayUrl?: string | null; pendingGatewayUrl?: string | null;
systemThemeCleanup?: (() => void) | null;
pendingGatewayToken?: string | null; pendingGatewayToken?: string | null;
}; };
@@ -176,17 +166,17 @@ export function setTab(host: SettingsHost, next: Tab) {
} }
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) { export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
const resolved = resolveTheme(next, host.themeMode);
const applyTheme = () => { const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next }); applySettings(host, { ...host.settings, theme: next });
applyResolvedTheme(host, resolveTheme(next, host.themeMode));
}; };
startThemeTransition({ startThemeTransition({
nextTheme: resolveTheme(next, host.themeMode), nextTheme: resolved,
applyTheme, applyTheme,
context, context,
currentTheme: host.themeResolved, currentTheme: host.themeResolved,
}); });
syncSystemThemeListener(host);
} }
export function setThemeMode( export function setThemeMode(
@@ -194,17 +184,17 @@ export function setThemeMode(
next: ThemeMode, next: ThemeMode,
context?: ThemeTransitionContext, context?: ThemeTransitionContext,
) { ) {
const applyTheme = () => { const resolved = resolveTheme(host.theme, next);
host.themeMode = next; const applyMode = () => {
applySettings(host, { ...host.settings, themeMode: next }); applySettings(host, { ...host.settings, themeMode: next });
applyResolvedTheme(host, resolveTheme(host.theme, next));
}; };
startThemeTransition({ startThemeTransition({
nextTheme: resolveTheme(host.theme, next), nextTheme: resolved,
applyTheme, applyTheme: applyMode,
context, context,
currentTheme: host.themeResolved, currentTheme: host.themeResolved,
}); });
syncSystemThemeListener(host);
} }
export async function refreshActiveTab(host: SettingsHost) { export async function refreshActiveTab(host: SettingsHost) {
@@ -228,7 +218,6 @@ export async function refreshActiveTab(host: SettingsHost) {
} }
if (host.tab === "agents") { if (host.tab === "agents") {
await loadAgents(host as unknown as OpenClawApp); await loadAgents(host as unknown as OpenClawApp);
await loadToolsCatalog(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp); await loadConfig(host as unknown as OpenClawApp);
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? []; const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) { if (agentIds.length > 0) {
@@ -262,7 +251,14 @@ export async function refreshActiveTab(host: SettingsHost) {
!host.chatHasAutoScrolled, !host.chatHasAutoScrolled,
); );
} }
if (host.tab === "config") { if (
host.tab === "config" ||
host.tab === "communications" ||
host.tab === "appearance" ||
host.tab === "automation" ||
host.tab === "infrastructure" ||
host.tab === "aiAgents"
) {
await loadConfigSchema(host as unknown as OpenClawApp); await loadConfigSchema(host as unknown as OpenClawApp);
await loadConfig(host as unknown as OpenClawApp); await loadConfig(host as unknown as OpenClawApp);
} }
@@ -289,9 +285,19 @@ export function inferBasePath() {
} }
export function syncThemeWithSettings(host: SettingsHost) { export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme; host.theme = host.settings.theme ?? "claw";
host.themeMode = host.settings.themeMode; host.themeMode = host.settings.themeMode ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode)); applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
syncSystemThemeListener(host);
}
export function attachThemeListener(host: SettingsHost) {
syncSystemThemeListener(host);
}
export function detachThemeListener(host: SettingsHost) {
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
} }
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@@ -300,45 +306,45 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
return; return;
} }
const root = document.documentElement; const root = document.documentElement;
root.dataset.theme = dataThemeForTheme(resolved); const themeMode = resolved.endsWith("light") ? "light" : "dark";
root.style.colorScheme = colorSchemeForTheme(resolved); root.dataset.theme = resolved;
root.dataset.themeMode = themeMode;
root.style.colorScheme = themeMode;
} }
export function attachThemeListener(host: SettingsHost) { function syncSystemThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") { // Clean up existing listener if mode is not "system"
if (host.themeMode !== "system") {
host.systemThemeCleanup?.();
host.systemThemeCleanup = null;
return; return;
} }
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
host.themeMediaHandler = (event) => { // Skip if listener already attached for this host
if (host.systemThemeCleanup) {
return;
}
if (typeof globalThis.matchMedia !== "function") {
return;
}
const mql = globalThis.matchMedia("(prefers-color-scheme: light)");
const onChange = () => {
if (host.themeMode !== "system") { if (host.themeMode !== "system") {
return; return;
} }
applyResolvedTheme(host, resolveTheme(host.theme, event.matches ? "dark" : "light")); applyResolvedTheme(host, resolveTheme(host.theme, "system"));
}; };
if (typeof host.themeMedia.addEventListener === "function") { if (typeof mql.addEventListener === "function") {
host.themeMedia.addEventListener("change", host.themeMediaHandler); mql.addEventListener("change", onChange);
host.systemThemeCleanup = () => mql.removeEventListener("change", onChange);
return; return;
} }
const legacy = host.themeMedia as MediaQueryList & { if (typeof mql.addListener === "function") {
addListener: (cb: (event: MediaQueryListEvent) => void) => void; mql.addListener(onChange);
}; host.systemThemeCleanup = () => mql.removeListener(onChange);
legacy.addListener(host.themeMediaHandler);
}
export function detachThemeListener(host: SettingsHost) {
if (!host.themeMedia || !host.themeMediaHandler) {
return;
} }
if (typeof host.themeMedia.removeEventListener === "function") {
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
return;
}
const legacy = host.themeMedia as MediaQueryList & {
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
};
legacy.removeListener(host.themeMediaHandler);
host.themeMedia = null;
host.themeMediaHandler = null;
} }
export function syncTabWithLocation(host: SettingsHost, replace: boolean) { export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
@@ -382,9 +388,16 @@ function applyTabSelection(
next: Tab, next: Tab,
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean }, options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
) { ) {
const prev = host.tab;
if (host.tab !== next) { if (host.tab !== next) {
host.tab = next; host.tab = next;
} }
// Cleanup chat module state when navigating away from chat
if (prev === "chat" && next !== "chat") {
resetChatViewState();
}
if (next === "chat") { if (next === "chat") {
host.chatHasAutoScrolled = false; host.chatHasAutoScrolled = false;
} }
@@ -447,13 +460,143 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
} }
export async function loadOverview(host: SettingsHost) { export async function loadOverview(host: SettingsHost) {
await Promise.all([ const app = host as unknown as OpenClawApp;
loadChannels(host as unknown as OpenClawApp, false), await Promise.allSettled([
loadPresence(host as unknown as OpenClawApp), loadChannels(app, false),
loadSessions(host as unknown as OpenClawApp), loadPresence(app),
loadCronStatus(host as unknown as OpenClawApp), loadSessions(app),
loadDebug(host as unknown as OpenClawApp), loadCronStatus(app),
loadCronJobs(app),
loadDebug(app),
loadSkills(app),
loadUsage(app),
loadOverviewLogs(app),
]); ]);
buildAttentionItems(app);
}
export function hasOperatorReadAccess(
auth: { role?: string; scopes?: readonly string[] } | null,
): boolean {
if (!auth?.scopes) {
return false;
}
return roleScopesAllow({
role: auth.role ?? "operator",
requestedScopes: ["operator.read"],
allowedScopes: auth.scopes,
});
}
export function hasMissingSkillDependencies(
missing: Record<string, unknown> | null | undefined,
): boolean {
if (!missing) {
return false;
}
return Object.values(missing).some((value) => Array.isArray(value) && value.length > 0);
}
async function loadOverviewLogs(host: OpenClawApp) {
if (!host.client || !host.connected) {
return;
}
try {
const res = await host.client.request("logs.tail", {
cursor: host.overviewLogCursor || undefined,
limit: 100,
maxBytes: 50_000,
});
const payload = res as {
cursor?: number;
lines?: unknown;
};
const lines = Array.isArray(payload.lines)
? payload.lines.filter((line): line is string => typeof line === "string")
: [];
host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500);
if (typeof payload.cursor === "number") {
host.overviewLogCursor = payload.cursor;
}
} catch {
/* non-critical */
}
}
function buildAttentionItems(host: OpenClawApp) {
const items: AttentionItem[] = [];
if (host.lastError) {
items.push({
severity: "error",
icon: "x",
title: "Gateway Error",
description: host.lastError,
});
}
const hello = host.hello;
const auth = (hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? null;
if (auth?.scopes && !hasOperatorReadAccess(auth)) {
items.push({
severity: "warning",
icon: "key",
title: "Missing operator.read scope",
description:
"This connection does not have the operator.read scope. Some features may be unavailable.",
href: "https://docs.openclaw.ai/web/dashboard",
external: true,
});
}
const skills = host.skillsReport?.skills ?? [];
const missingDeps = skills.filter((s) => !s.disabled && hasMissingSkillDependencies(s.missing));
if (missingDeps.length > 0) {
const names = missingDeps.slice(0, 3).map((s) => s.name);
const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : "";
items.push({
severity: "warning",
icon: "zap",
title: "Skills with missing dependencies",
description: `${names.join(", ")}${more}`,
});
}
const blocked = skills.filter((s) => s.blockedByAllowlist);
if (blocked.length > 0) {
items.push({
severity: "warning",
icon: "shield",
title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`,
description: blocked.map((s) => s.name).join(", "),
});
}
const cronJobs = host.cronJobs ?? [];
const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error");
if (failedCron.length > 0) {
items.push({
severity: "error",
icon: "clock",
title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`,
description: failedCron.map((j) => j.name).join(", "),
});
}
const now = Date.now();
const overdue = cronJobs.filter(
(j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000,
);
if (overdue.length > 0) {
items.push({
severity: "warning",
icon: "clock",
title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`,
description: overdue.map((j) => j.name).join(", "),
});
}
host.attentionItems = items;
} }
export async function loadChannelsTab(host: SettingsHost) { export async function loadChannelsTab(host: SettingsHost) {
@@ -465,18 +608,12 @@ export async function loadChannelsTab(host: SettingsHost) {
} }
export async function loadCron(host: SettingsHost) { export async function loadCron(host: SettingsHost) {
const cronHost = host as unknown as OpenClawApp; const app = host as unknown as OpenClawApp;
const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null;
await Promise.all([ await Promise.all([
loadChannels(host as unknown as OpenClawApp, false), loadChannels(app, false),
loadCronStatus(cronHost), loadCronStatus(app),
loadCronJobs(cronHost), loadCronJobs(app),
loadCronModelSuggestions(cronHost), loadCronRuns(app, activeCronJobId),
]); ]);
if (cronHost.cronRunsScope === "all") {
await loadCronRuns(cronHost, null);
return;
}
if (cronHost.cronRunsJobId) {
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
}
} }

View File

@@ -53,8 +53,8 @@ import {
} from "./app-tool-stream.ts"; } from "./app-tool-stream.ts";
import type { AppViewState } from "./app-view-state.ts"; import type { AppViewState } from "./app-view-state.ts";
import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts";
import { exportChatMarkdown } from "./chat/export.ts";
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
import type { CronFieldErrors } from "./controllers/cron.ts";
import type { DevicePairingList } from "./controllers/devices.ts"; import type { DevicePairingList } from "./controllers/devices.ts";
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
@@ -62,7 +62,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts"; import type { Tab } from "./navigation.ts";
import { loadSettings, type UiSettings } from "./storage.ts"; import { loadSettings, type UiSettings } from "./storage.ts";
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts"; import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
import type { import type {
AgentsListResult, AgentsListResult,
AgentsFilesListResult, AgentsFilesListResult,
@@ -72,16 +72,17 @@ import type {
CronJob, CronJob,
CronRunLogEntry, CronRunLogEntry,
CronStatus, CronStatus,
HealthSnapshot, HealthSummary,
LogEntry, LogEntry,
LogLevel, LogLevel,
ModelCatalogEntry,
PresenceEntry, PresenceEntry,
ChannelsStatusSnapshot, ChannelsStatusSnapshot,
SessionsListResult, SessionsListResult,
SkillStatusReport, SkillStatusReport,
ToolsCatalogResult,
StatusSummary, StatusSummary,
NostrProfile, NostrProfile,
ToolsCatalogResult,
} from "./types.ts"; } from "./types.ts";
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts"; import { generateUUID } from "./uuid.ts";
@@ -121,12 +122,15 @@ export class OpenClawApp extends LitElement {
} }
} }
@state() password = ""; @state() password = "";
@state() loginShowGatewayToken = false;
@state() loginShowGatewayPassword = false;
@state() tab: Tab = "chat"; @state() tab: Tab = "chat";
@state() onboarding = resolveOnboardingMode(); @state() onboarding = resolveOnboardingMode();
@state() connected = false; @state() connected = false;
@state() theme: ThemeName = this.settings.theme; @state() theme: ThemeName = this.settings.theme ?? "claw";
@state() themeMode: ThemeMode = this.settings.themeMode; @state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
@state() themeResolved: ResolvedTheme = "dark"; @state() themeResolved: ResolvedTheme = "dark";
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
@state() hello: GatewayHelloOk | null = null; @state() hello: GatewayHelloOk | null = null;
@state() lastError: string | null = null; @state() lastError: string | null = null;
@state() lastErrorCode: string | null = null; @state() lastErrorCode: string | null = null;
@@ -157,6 +161,9 @@ export class OpenClawApp extends LitElement {
@state() chatQueue: ChatQueueItem[] = []; @state() chatQueue: ChatQueueItem[] = [];
@state() chatAttachments: ChatAttachment[] = []; @state() chatAttachments: ChatAttachment[] = [];
@state() chatManualRefreshInFlight = false; @state() chatManualRefreshInFlight = false;
onSlashAction?: (action: string) => void;
// Sidebar state for tool output viewing // Sidebar state for tool output viewing
@state() sidebarOpen = false; @state() sidebarOpen = false;
@state() sidebarContent: string | null = null; @state() sidebarContent: string | null = null;
@@ -203,6 +210,26 @@ export class OpenClawApp extends LitElement {
@state() configSearchQuery = ""; @state() configSearchQuery = "";
@state() configActiveSection: string | null = null; @state() configActiveSection: string | null = null;
@state() configActiveSubsection: string | null = null; @state() configActiveSubsection: string | null = null;
@state() communicationsFormMode: "form" | "raw" = "form";
@state() communicationsSearchQuery = "";
@state() communicationsActiveSection: string | null = null;
@state() communicationsActiveSubsection: string | null = null;
@state() appearanceFormMode: "form" | "raw" = "form";
@state() appearanceSearchQuery = "";
@state() appearanceActiveSection: string | null = null;
@state() appearanceActiveSubsection: string | null = null;
@state() automationFormMode: "form" | "raw" = "form";
@state() automationSearchQuery = "";
@state() automationActiveSection: string | null = null;
@state() automationActiveSubsection: string | null = null;
@state() infrastructureFormMode: "form" | "raw" = "form";
@state() infrastructureSearchQuery = "";
@state() infrastructureActiveSection: string | null = null;
@state() infrastructureActiveSubsection: string | null = null;
@state() aiAgentsFormMode: "form" | "raw" = "form";
@state() aiAgentsSearchQuery = "";
@state() aiAgentsActiveSection: string | null = null;
@state() aiAgentsActiveSubsection: string | null = null;
@state() channelsLoading = false; @state() channelsLoading = false;
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null; @state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
@@ -252,6 +279,12 @@ export class OpenClawApp extends LitElement {
@state() sessionsIncludeGlobal = true; @state() sessionsIncludeGlobal = true;
@state() sessionsIncludeUnknown = false; @state() sessionsIncludeUnknown = false;
@state() sessionsHideCron = true; @state() sessionsHideCron = true;
@state() sessionsSearchQuery = "";
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
@state() sessionsSortDir: "asc" | "desc" = "desc";
@state() sessionsPage = 0;
@state() sessionsPageSize = 10;
@state() sessionsActionsOpenKey: string | null = null;
@state() usageLoading = false; @state() usageLoading = false;
@state() usageResult: import("./types.js").SessionsUsageResult | null = null; @state() usageResult: import("./types.js").SessionsUsageResult | null = null;
@@ -326,7 +359,7 @@ export class OpenClawApp extends LitElement {
@state() cronStatus: CronStatus | null = null; @state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null; @state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronFieldErrors: CronFieldErrors = {}; @state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {};
@state() cronEditingJobId: string | null = null; @state() cronEditingJobId: string | null = null;
@state() cronRunsJobId: string | null = null; @state() cronRunsJobId: string | null = null;
@state() cronRunsLoadingMore = false; @state() cronRunsLoadingMore = false;
@@ -346,6 +379,16 @@ export class OpenClawApp extends LitElement {
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null; @state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
// Overview dashboard state
@state() attentionItems: import("./types.js").AttentionItem[] = [];
@state() paletteOpen = false;
@state() paletteQuery = "";
@state() paletteActiveIndex = 0;
@state() overviewShowGatewayToken = false;
@state() overviewShowGatewayPassword = false;
@state() overviewLogLines: string[] = [];
@state() overviewLogCursor = 0;
@state() skillsLoading = false; @state() skillsLoading = false;
@state() skillsReport: SkillStatusReport | null = null; @state() skillsReport: SkillStatusReport | null = null;
@state() skillsError: string | null = null; @state() skillsError: string | null = null;
@@ -354,10 +397,14 @@ export class OpenClawApp extends LitElement {
@state() skillsBusyKey: string | null = null; @state() skillsBusyKey: string | null = null;
@state() skillMessages: Record<string, SkillMessage> = {}; @state() skillMessages: Record<string, SkillMessage> = {};
@state() healthLoading = false;
@state() healthResult: HealthSummary | null = null;
@state() healthError: string | null = null;
@state() debugLoading = false; @state() debugLoading = false;
@state() debugStatus: StatusSummary | null = null; @state() debugStatus: StatusSummary | null = null;
@state() debugHealth: HealthSnapshot | null = null; @state() debugHealth: HealthSummary | null = null;
@state() debugModels: unknown[] = []; @state() debugModels: ModelCatalogEntry[] = [];
@state() debugHeartbeat: unknown = null; @state() debugHeartbeat: unknown = null;
@state() debugCallMethod = ""; @state() debugCallMethod = "";
@state() debugCallParams = "{}"; @state() debugCallParams = "{}";
@@ -396,9 +443,17 @@ export class OpenClawApp extends LitElement {
basePath = ""; basePath = "";
private popStateHandler = () => private popStateHandler = () =>
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]); onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null; private topbarObserver: ResizeObserver | null = null;
private globalKeydownHandler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "k") {
e.preventDefault();
this.paletteOpen = !this.paletteOpen;
if (this.paletteOpen) {
this.paletteQuery = "";
this.paletteActiveIndex = 0;
}
}
};
createRenderRoot() { createRenderRoot() {
return this; return this;
@@ -406,6 +461,20 @@ export class OpenClawApp extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.onSlashAction = (action: string) => {
switch (action) {
case "toggle-focus":
this.applySettings({
...this.settings,
chatFocusMode: !this.settings.chatFocusMode,
});
break;
case "export":
exportChatMarkdown(this.chatMessages, this.assistantName);
break;
}
};
document.addEventListener("keydown", this.globalKeydownHandler);
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]); handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
} }
@@ -414,6 +483,7 @@ export class OpenClawApp extends LitElement {
} }
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler);
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]); handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -475,6 +545,7 @@ export class OpenClawApp extends LitElement {
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) { setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context); setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this.themeOrder = this.buildThemeOrder(next);
} }
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) { setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
@@ -485,6 +556,12 @@ export class OpenClawApp extends LitElement {
); );
} }
buildThemeOrder(active: ThemeName): ThemeName[] {
const all = [...VALID_THEME_NAMES];
const rest = all.filter((id) => id !== active);
return [active, ...rest];
}
async loadOverview() { async loadOverview() {
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]); await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
} }

View File

@@ -0,0 +1,5 @@
export const CHAT_ATTACHMENT_ACCEPT = "image/*";
export function isSupportedChatAttachmentMimeType(mimeType: string | null | undefined): boolean {
return typeof mimeType === "string" && mimeType.startsWith("image/");
}

View File

@@ -44,6 +44,10 @@ export class DeletedMessages {
} }
private save(): void { private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._keys])); try {
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
} catch {
// ignore
}
} }
} }

View File

@@ -1,58 +1,8 @@
import { extractTextCached } from "./message-extract.ts";
/** /**
* Export chat history as markdown file. * Export chat history as markdown file.
*/ */
export function escapeHtmlInMarkdown(text: string): string {
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
export function normalizeSingleLineLabel(label: string, fallback = "Assistant"): string {
const normalized = label.replace(/[\r\n\t]+/g, " ").trim();
return normalized || fallback;
}
export function sanitizeFilenameComponent(input: string): string {
const normalized = normalizeSingleLineLabel(input, "assistant").normalize("NFKC");
const sanitized = normalized
.replace(/[\\/]/g, "-")
.replace(/[^a-zA-Z0-9 _.-]/g, "")
.replace(/\s+/g, " ")
.replace(/-+/g, "-")
.trim()
.replace(/^[.-]+/, "")
.slice(0, 50);
return sanitized || "assistant";
}
export function buildChatMarkdown(messages: unknown[], assistantNameRaw: string): string | null {
const assistantName = escapeHtmlInMarkdown(normalizeSingleLineLabel(assistantNameRaw));
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return null;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = escapeHtmlInMarkdown(
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? (m.content as Array<{ type?: string; text?: string }>)
.filter((b) => b?.type === "text" && typeof b.text === "string")
.map((b) => b.text)
.join("")
: "",
);
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
return lines.join("\n");
}
export function buildChatExportFilename(assistantNameRaw: string, now = Date.now()): string {
return `chat-${sanitizeFilenameComponent(assistantNameRaw)}-${now}.md`;
}
export function exportChatMarkdown(messages: unknown[], assistantName: string): void { export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
const markdown = buildChatMarkdown(messages, assistantName); const markdown = buildChatMarkdown(messages, assistantName);
if (!markdown) { if (!markdown) {
@@ -62,7 +12,23 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string):
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = buildChatExportFilename(assistantName); link.download = `chat-${assistantName}-${Date.now()}.md`;
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
export function buildChatMarkdown(messages: unknown[], assistantName: string): string | null {
const history = Array.isArray(messages) ? messages : [];
if (history.length === 0) {
return null;
}
const lines: string[] = [`# Chat with ${assistantName}`, ""];
for (const msg of history) {
const m = msg as Record<string, unknown>;
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
const content = extractTextCached(msg) ?? "";
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
}
return lines.join("\n");
}

View File

@@ -1,10 +1,12 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity.ts"; import type { AssistantIdentity } from "../assistant-identity.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts"; import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { openExternalUrlSafe } from "../open-external-url.ts"; import { openExternalUrlSafe } from "../open-external-url.ts";
import { detectTextDirection } from "../text-direction.ts"; import { detectTextDirection } from "../text-direction.ts";
import type { MessageGroup } from "../types/chat-types.ts"; import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
import { agentLogoUrl } from "../views/agents-utils.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts"; import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import { import {
extractTextCached, extractTextCached,
@@ -12,6 +14,7 @@ import {
formatReasoningMarkdown, formatReasoningMarkdown,
} from "./message-extract.ts"; } from "./message-extract.ts";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts"; import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
type ImageBlock = { type ImageBlock = {
@@ -56,10 +59,10 @@ function extractImages(message: unknown): ImageBlock[] {
return images; return images;
} }
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) { export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
${renderAvatar("assistant", assistant)} ${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages"> <div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true"> <div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots"> <span class="chat-reading-indicator__dots">
@@ -76,6 +79,7 @@ export function renderStreamingGroup(
startedAt: number, startedAt: number,
onOpenSidebar?: (content: string) => void, onOpenSidebar?: (content: string) => void,
assistant?: AssistantIdentity, assistant?: AssistantIdentity,
basePath?: string,
) { ) {
const timestamp = new Date(startedAt).toLocaleTimeString([], { const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric", hour: "numeric",
@@ -85,7 +89,7 @@ export function renderStreamingGroup(
return html` return html`
<div class="chat-group assistant"> <div class="chat-group assistant">
${renderAvatar("assistant", assistant)} ${renderAvatar("assistant", assistant, basePath)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${renderGroupedMessage( ${renderGroupedMessage(
{ {
@@ -112,6 +116,9 @@ export function renderMessageGroup(
showReasoning: boolean; showReasoning: boolean;
assistantName?: string; assistantName?: string;
assistantAvatar?: string | null; assistantAvatar?: string | null;
basePath?: string;
contextWindow?: number | null;
onDelete?: () => void;
}, },
) { ) {
const normalizedRole = normalizeRoleForGrouping(group.role); const normalizedRole = normalizeRoleForGrouping(group.role);
@@ -122,20 +129,35 @@ export function renderMessageGroup(
? (userLabel ?? "You") ? (userLabel ?? "You")
: normalizedRole === "assistant" : normalizedRole === "assistant"
? assistantName ? assistantName
: normalizedRole; : normalizedRole === "tool"
? "Tool"
: normalizedRole;
const roleClass = const roleClass =
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other"; normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: normalizedRole === "tool"
? "tool"
: "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], { const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
}); });
// Aggregate usage/cost/model across all messages in the group
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
return html` return html`
<div class="chat-group ${roleClass}"> <div class="chat-group ${roleClass}">
${renderAvatar(group.role, { ${renderAvatar(
name: assistantName, group.role,
avatar: opts.assistantAvatar ?? null, {
})} name: assistantName,
avatar: opts.assistantAvatar ?? null,
},
opts.basePath,
)}
<div class="chat-group-messages"> <div class="chat-group-messages">
${group.messages.map((item, index) => ${group.messages.map((item, index) =>
renderGroupedMessage( renderGroupedMessage(
@@ -150,24 +172,304 @@ export function renderMessageGroup(
<div class="chat-group-footer"> <div class="chat-group-footer">
<span class="chat-sender-name">${who}</span> <span class="chat-sender-name">${who}</span>
<span class="chat-group-timestamp">${timestamp}</span> <span class="chat-group-timestamp">${timestamp}</span>
${renderMessageMeta(meta)}
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing}
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) { // ── Per-message metadata (tokens, cost, model, context %) ──
type GroupMeta = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
model: string | null;
contextPercent: number | null;
};
function extractGroupMeta(group: MessageGroup, contextWindow: number | null): GroupMeta | null {
let input = 0;
let output = 0;
let cacheRead = 0;
let cacheWrite = 0;
let cost = 0;
let model: string | null = null;
let hasUsage = false;
for (const { message } of group.messages) {
const m = message as Record<string, unknown>;
if (m.role !== "assistant") {
continue;
}
const usage = m.usage as Record<string, number> | undefined;
if (usage) {
hasUsage = true;
input += usage.input ?? usage.inputTokens ?? 0;
output += usage.output ?? usage.outputTokens ?? 0;
cacheRead += usage.cacheRead ?? usage.cache_read_input_tokens ?? 0;
cacheWrite += usage.cacheWrite ?? usage.cache_creation_input_tokens ?? 0;
}
const c = m.cost as Record<string, number> | undefined;
if (c?.total) {
cost += c.total;
}
if (typeof m.model === "string" && m.model !== "gateway-injected") {
model = m.model;
}
}
if (!hasUsage && !model) {
return null;
}
const contextPercent =
contextWindow && input > 0 ? Math.min(Math.round((input / contextWindow) * 100), 100) : null;
return { input, output, cacheRead, cacheWrite, cost, model, contextPercent };
}
/** Compact token count formatter (e.g. 128000 → "128k"). */
function fmtTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
}
return String(n);
}
function renderMessageMeta(meta: GroupMeta | null) {
if (!meta) {
return nothing;
}
const parts: Array<ReturnType<typeof html>> = [];
// Token counts: ↑input ↓output
if (meta.input) {
parts.push(html`<span class="msg-meta__tokens">↑${fmtTokens(meta.input)}</span>`);
}
if (meta.output) {
parts.push(html`<span class="msg-meta__tokens">↓${fmtTokens(meta.output)}</span>`);
}
// Cache: R/W
if (meta.cacheRead) {
parts.push(html`<span class="msg-meta__cache">R${fmtTokens(meta.cacheRead)}</span>`);
}
if (meta.cacheWrite) {
parts.push(html`<span class="msg-meta__cache">W${fmtTokens(meta.cacheWrite)}</span>`);
}
// Cost
if (meta.cost > 0) {
parts.push(html`<span class="msg-meta__cost">$${meta.cost.toFixed(4)}</span>`);
}
// Context %
if (meta.contextPercent !== null) {
const pct = meta.contextPercent;
const cls =
pct >= 90
? "msg-meta__ctx msg-meta__ctx--danger"
: pct >= 75
? "msg-meta__ctx msg-meta__ctx--warn"
: "msg-meta__ctx";
parts.push(html`<span class="${cls}">${pct}% ctx</span>`);
}
// Model
if (meta.model) {
// Shorten model name: strip provider prefix if present (e.g. "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet")
const shortModel = meta.model.includes("/") ? meta.model.split("/").pop()! : meta.model;
parts.push(html`<span class="msg-meta__model">${shortModel}</span>`);
}
if (parts.length === 0) {
return nothing;
}
return html`<span class="msg-meta">${parts}</span>`;
}
function extractGroupText(group: MessageGroup): string {
const parts: string[] = [];
for (const { message } of group.messages) {
const text = extractTextCached(message);
if (text?.trim()) {
parts.push(text.trim());
}
}
return parts.join("\n\n");
}
const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm";
function shouldSkipDeleteConfirm(): boolean {
try {
return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1";
} catch {
return false;
}
}
function renderDeleteButton(onDelete: () => void) {
return html`
<span class="chat-delete-wrap">
<button
class="chat-group-delete"
title="Delete"
aria-label="Delete message"
@click=${(e: Event) => {
if (shouldSkipDeleteConfirm()) {
onDelete();
return;
}
const btn = e.currentTarget as HTMLElement;
const wrap = btn.closest(".chat-delete-wrap") as HTMLElement;
const existing = wrap?.querySelector(".chat-delete-confirm");
if (existing) {
existing.remove();
return;
}
const popover = document.createElement("div");
popover.className = "chat-delete-confirm";
popover.innerHTML = `
<p class="chat-delete-confirm__text">Delete this message?</p>
<label class="chat-delete-confirm__remember">
<input type="checkbox" class="chat-delete-confirm__check" />
<span>Don't ask again</span>
</label>
<div class="chat-delete-confirm__actions">
<button class="chat-delete-confirm__cancel" type="button">Cancel</button>
<button class="chat-delete-confirm__yes" type="button">Delete</button>
</div>
`;
wrap.appendChild(popover);
const cancel = popover.querySelector(".chat-delete-confirm__cancel")!;
const yes = popover.querySelector(".chat-delete-confirm__yes")!;
const check = popover.querySelector(".chat-delete-confirm__check") as HTMLInputElement;
cancel.addEventListener("click", () => popover.remove());
yes.addEventListener("click", () => {
if (check.checked) {
try {
localStorage.setItem(SKIP_DELETE_CONFIRM_KEY, "1");
} catch {}
}
popover.remove();
onDelete();
});
// Close on click outside
const closeOnOutside = (evt: MouseEvent) => {
if (!popover.contains(evt.target as Node) && evt.target !== btn) {
popover.remove();
document.removeEventListener("click", closeOnOutside, true);
}
};
requestAnimationFrame(() => document.addEventListener("click", closeOnOutside, true));
}}
>${icons.trash ?? icons.x}</button>
</span>
`;
}
function renderTtsButton(group: MessageGroup) {
return html`
<button
class="chat-tts-btn"
type="button"
title=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
aria-label=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLButtonElement;
if (isTtsSpeaking()) {
stopTts();
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
return;
}
const text = extractGroupText(group);
if (!text) {
return;
}
btn.classList.add("chat-tts-btn--active");
btn.title = "Stop speaking";
speakText(text, {
onEnd: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
}
},
onError: () => {
if (btn.isConnected) {
btn.classList.remove("chat-tts-btn--active");
btn.title = "Read aloud";
}
},
});
}}
>
${icons.volume2}
</button>
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
basePath?: string,
) {
const normalized = normalizeRoleForGrouping(role); const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant"; const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || ""; const assistantAvatar = assistant?.avatar?.trim() || "";
const initial = const initial =
normalized === "user" normalized === "user"
? "U" ? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="8" r="4" />
<path d="M20 21a8 8 0 1 0-16 0" />
</svg>
`
: normalized === "assistant" : normalized === "assistant"
? assistantName.charAt(0).toUpperCase() || "A" ? html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
</svg>
`
: normalized === "tool" : normalized === "tool"
? "⚙" ? html`
: "?"; <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
/>
</svg>
`
: html`
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
<circle cx="12" cy="12" r="10" />
<text
x="12"
y="16.5"
text-anchor="middle"
font-size="14"
font-weight="600"
fill="var(--bg, #fff)"
>
?
</text>
</svg>
`;
const className = const className =
normalized === "user" normalized === "user"
? "user" ? "user"
@@ -185,7 +487,21 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
alt="${assistantName}" alt="${assistantName}"
/>`; />`;
} }
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`; return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
alt="${assistantName}"
/>`;
}
/* Assistant with no custom avatar: use logo when basePath available */
if (normalized === "assistant" && basePath) {
const logoUrl = agentLogoUrl(basePath);
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${logoUrl}"
alt="${assistantName}"
/>`;
} }
return html`<div class="chat-avatar ${className}">${initial}</div>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
@@ -222,6 +538,79 @@ function renderMessageImages(images: ImageBlock[]) {
`; `;
} }
/** Render tool cards inside a collapsed `<details>` element. */
function renderCollapsedToolCards(
toolCards: ToolCard[],
onOpenSidebar?: (content: string) => void,
) {
const calls = toolCards.filter((c) => c.kind === "call");
const results = toolCards.filter((c) => c.kind === "result");
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const summaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
return html`
<details class="chat-tools-collapse">
<summary class="chat-tools-summary">
<span class="chat-tools-summary__icon">${icons.zap}</span>
<span class="chat-tools-summary__count">${totalTools} tool${totalTools === 1 ? "" : "s"}</span>
<span class="chat-tools-summary__names">${summaryLabel}</span>
</summary>
<div class="chat-tools-collapse__body">
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
</details>
`;
}
/**
* Max characters for auto-detecting and pretty-printing JSON.
* Prevents DoS from large JSON payloads in assistant/tool messages.
*/
const MAX_JSON_AUTOPARSE_CHARS = 20_000;
/**
* Detect whether a trimmed string is a JSON object or array.
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
* Size-capped to prevent render-loop DoS from large JSON messages.
*/
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
const t = text.trim();
// Enforce size cap to prevent UI freeze from multi-MB JSON payloads
if (t.length > MAX_JSON_AUTOPARSE_CHARS) {
return null;
}
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
try {
const parsed = JSON.parse(t);
return { parsed, pretty: JSON.stringify(parsed, null, 2) };
} catch {
return null;
}
}
return null;
}
/** Build a short summary label for collapsed JSON (type + key count or array length). */
function jsonSummaryLabel(parsed: unknown): string {
if (Array.isArray(parsed)) {
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`;
}
if (parsed && typeof parsed === "object") {
const keys = Object.keys(parsed as Record<string, unknown>);
if (keys.length <= 4) {
return `{ ${keys.join(", ")} }`;
}
return `Object (${keys.length} keys)`;
}
return "JSON";
}
function renderGroupedMessage( function renderGroupedMessage(
message: unknown, message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean }, opts: { isStreaming: boolean; showReasoning: boolean },
@@ -229,6 +618,7 @@ function renderGroupedMessage(
) { ) {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown"; const role = typeof m.role === "string" ? m.role : "unknown";
const normalizedRole = normalizeRoleForGrouping(role);
const isToolResult = const isToolResult =
isToolResultMessage(message) || isToolResultMessage(message) ||
role.toLowerCase() === "toolresult" || role.toLowerCase() === "toolresult" ||
@@ -249,40 +639,99 @@ function renderGroupedMessage(
const markdown = markdownBase; const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim()); const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [ // Detect pure-JSON messages and render as collapsible block
"chat-bubble", const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "", const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
"fade-in",
]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");
if (!markdown && hasToolCards && isToolResult) { if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`; return renderCollapsedToolCards(toolCards, onOpenSidebar);
} }
if (!markdown && !hasToolCards && !hasImages) { if (!markdown && !hasToolCards && !hasImages) {
return nothing; return nothing;
} }
const isToolMessage = normalizedRole === "tool" || isToolResult;
const toolNames = [...new Set(toolCards.map((c) => c.name))];
const toolSummaryLabel =
toolNames.length <= 3
? toolNames.join(", ")
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
const toolPreview =
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : "";
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} ${canCopyMarkdown ? html`<div class="chat-bubble-actions">${renderCopyAsMarkdownButton(markdown!)}</div>` : nothing}
${renderMessageImages(images)}
${ ${
reasoningMarkdown isToolMessage
? html`<div class="chat-thinking">${unsafeHTML( ? html`
toSanitizedMarkdownHtml(reasoningMarkdown), <details class="chat-tool-msg-collapse">
)}</div>` <summary class="chat-tool-msg-summary">
: nothing <span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">Tool output</span>
${
toolSummaryLabel
? html`<span class="chat-tool-msg-summary__names">${toolSummaryLabel}</span>`
: toolPreview
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
: nothing
}
</summary>
<div class="chat-tool-msg-body">
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
</div>
</details>
`
: html`
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
`
} }
${
markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div> </div>
`; `;
} }

View File

@@ -56,6 +56,10 @@ export class PinnedMessages {
} }
private save(): void { private save(): void {
localStorage.setItem(this.key, JSON.stringify([...this._indices])); try {
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
} catch {
// ignore
}
} }
} }

View File

@@ -0,0 +1,5 @@
import { extractTextCached } from "./message-extract.ts";
export function getPinnedMessageSummary(message: unknown): string {
return extractTextCached(message) ?? "";
}

View File

@@ -0,0 +1,10 @@
import { extractTextCached } from "./message-extract.ts";
export function messageMatchesSearchQuery(message: unknown, query: string): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
const text = (extractTextCached(message) ?? "").toLowerCase();
return text.includes(normalizedQuery);
}

View File

@@ -0,0 +1,26 @@
export const MAX_CACHED_CHAT_SESSIONS = 20;
export function getOrCreateSessionCacheValue<T>(
map: Map<string, T>,
sessionKey: string,
create: () => T,
): T {
if (map.has(sessionKey)) {
const existing = map.get(sessionKey) as T;
// Refresh insertion order so recently used sessions stay cached.
map.delete(sessionKey);
map.set(sessionKey, existing);
return existing;
}
const created = create();
map.set(sessionKey, created);
while (map.size > MAX_CACHED_CHAT_SESSIONS) {
const oldest = map.keys().next().value;
if (typeof oldest !== "string") {
break;
}
map.delete(oldest);
}
return created;
}

View File

@@ -4,14 +4,13 @@
*/ */
import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js"; import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
import { import {
formatThinkingLevels, formatThinkingLevels,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
resolveThinkingDefaultForModel,
} from "../../../../src/auto-reply/thinking.js"; } from "../../../../src/auto-reply/thinking.js";
import type { HealthSummary } from "../../../../src/commands/health.js"; import type { HealthSummary } from "../../../../src/commands/health.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { import {
DEFAULT_AGENT_ID, DEFAULT_AGENT_ID,
DEFAULT_MAIN_KEY, DEFAULT_MAIN_KEY,
@@ -176,6 +175,7 @@ async function executeThink(
args: string, args: string,
): Promise<SlashCommandResult> { ): Promise<SlashCommandResult> {
const rawLevel = args.trim(); const rawLevel = args.trim();
if (!rawLevel) { if (!rawLevel) {
try { try {
const { session, models } = await loadThinkingCommandState(client, sessionKey); const { session, models } = await loadThinkingCommandState(client, sessionKey);
@@ -219,6 +219,7 @@ async function executeVerbose(
args: string, args: string,
): Promise<SlashCommandResult> { ): Promise<SlashCommandResult> {
const rawLevel = args.trim(); const rawLevel = args.trim();
if (!rawLevel) { if (!rawLevel) {
try { try {
const session = await loadCurrentSession(client, sessionKey); const session = await loadCurrentSession(client, sessionKey);
@@ -526,8 +527,7 @@ function resolveCurrentThinkingLevel(
if (!session?.modelProvider || !session.model) { if (!session?.modelProvider || !session.model) {
return "off"; return "off";
} }
return resolveThinkingDefault({ return resolveThinkingDefaultForModel({
cfg: {} as OpenClawConfig,
provider: session.modelProvider, provider: session.modelProvider,
model: session.model, model: session.model,
catalog: models, catalog: models,

View File

@@ -21,14 +21,14 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
{ {
name: "new", name: "new",
description: "Start a new session", description: "Start a new session",
icon: "circle", icon: "plus",
category: "session", category: "session",
executeLocal: true, executeLocal: true,
}, },
{ {
name: "reset", name: "reset",
description: "Reset current session", description: "Reset current session",
icon: "loader", icon: "refresh",
category: "session", category: "session",
executeLocal: true, executeLocal: true,
}, },
@@ -42,21 +42,21 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
{ {
name: "stop", name: "stop",
description: "Stop current run", description: "Stop current run",
icon: "x", icon: "stop",
category: "session", category: "session",
executeLocal: true, executeLocal: true,
}, },
{ {
name: "clear", name: "clear",
description: "Clear chat history", description: "Clear chat history",
icon: "x", icon: "trash",
category: "session", category: "session",
executeLocal: true, executeLocal: true,
}, },
{ {
name: "focus", name: "focus",
description: "Toggle focus mode", description: "Toggle focus mode",
icon: "search", icon: "eye",
category: "session", category: "session",
executeLocal: true, executeLocal: true,
}, },
@@ -77,13 +77,13 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
icon: "brain", icon: "brain",
category: "model", category: "model",
executeLocal: true, executeLocal: true,
argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"], argOptions: ["off", "low", "medium", "high"],
}, },
{ {
name: "verbose", name: "verbose",
description: "Toggle verbose mode", description: "Toggle verbose mode",
args: "<on|off|full>", args: "<on|off|full>",
icon: "fileCode", icon: "terminal",
category: "model", category: "model",
executeLocal: true, executeLocal: true,
argOptions: ["on", "off", "full"], argOptions: ["on", "off", "full"],
@@ -107,7 +107,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
{ {
name: "export", name: "export",
description: "Export session to Markdown", description: "Export session to Markdown",
icon: "arrowDown", icon: "download",
category: "tools", category: "tools",
executeLocal: true, executeLocal: true,
}, },
@@ -146,7 +146,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
name: "steer", name: "steer",
description: "Steer a sub-agent", description: "Steer a sub-agent",
args: "<id> <msg>", args: "<id> <msg>",
icon: "zap", icon: "send",
category: "agents", category: "agents",
}, },
]; ];

View File

@@ -11,6 +11,7 @@ export type AgentsState = {
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
agentsSelectedId: string | null; agentsSelectedId: string | null;
toolsCatalogLoading: boolean; toolsCatalogLoading: boolean;
toolsCatalogLoadingAgentId?: string | null;
toolsCatalogError: string | null; toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null; toolsCatalogResult: ToolsCatalogResult | null;
}; };
@@ -43,27 +44,44 @@ export async function loadAgents(state: AgentsState) {
} }
} }
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) { export async function loadToolsCatalog(state: AgentsState, agentId: string) {
if (!state.client || !state.connected) { const resolvedAgentId = agentId.trim();
if (!state.client || !state.connected || !resolvedAgentId) {
return; return;
} }
if (state.toolsCatalogLoading) { if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
return; return;
} }
state.toolsCatalogLoading = true; state.toolsCatalogLoading = true;
state.toolsCatalogLoadingAgentId = resolvedAgentId;
state.toolsCatalogError = null; state.toolsCatalogError = null;
state.toolsCatalogResult = null;
try { try {
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", { const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
agentId: agentId ?? state.agentsSelectedId ?? undefined, agentId: resolvedAgentId,
includePlugins: true, includePlugins: true,
}); });
if (res) { if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
state.toolsCatalogResult = res; return;
} }
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = res;
} catch (err) { } catch (err) {
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
return;
}
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
return;
}
state.toolsCatalogResult = null;
state.toolsCatalogError = String(err); state.toolsCatalogError = String(err);
} finally { } finally {
state.toolsCatalogLoading = false; if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
state.toolsCatalogLoadingAgentId = null;
state.toolsCatalogLoading = false;
}
} }
} }

View File

@@ -184,9 +184,17 @@ export async function runUpdate(state: ConfigState) {
state.updateRunning = true; state.updateRunning = true;
state.lastError = null; state.lastError = null;
try { try {
await state.client.request("update.run", { const res = await state.client.request<{
ok?: boolean;
result?: { status?: string; reason?: string };
}>("update.run", {
sessionKey: state.applySessionKey, sessionKey: state.applySessionKey,
}); });
if (res && res.ok === false) {
const status = res.result?.status ?? "error";
const reason = res.result?.reason ?? "Update failed.";
state.lastError = `Update ${status}: ${reason}`;
}
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
} finally { } finally {
@@ -255,3 +263,21 @@ export function ensureAgentConfigEntry(state: ConfigState, agentId: string): num
updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId); updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId);
return nextIndex; return nextIndex;
} }
export async function openConfigFile(state: ConfigState): Promise<void> {
if (!state.client || !state.connected) {
return;
}
try {
await state.client.request("config.openFile", {});
} catch {
const path = state.configSnapshot?.path;
if (path) {
try {
await navigator.clipboard.writeText(path);
} catch {
// ignore
}
}
}
}

View File

@@ -0,0 +1,62 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { HealthSummary } from "../types.ts";
/** Default fallback returned when the gateway is unreachable or returns null. */
const HEALTH_FALLBACK: HealthSummary = {
ok: false,
ts: 0,
durationMs: 0,
heartbeatSeconds: 0,
defaultAgentId: "",
agents: [],
sessions: { path: "", count: 0, recent: [] },
};
/** State slice consumed by {@link loadHealthState}. Follows the agents/sessions convention. */
export type HealthState = {
client: GatewayBrowserClient | null;
connected: boolean;
healthLoading: boolean;
healthResult: HealthSummary | null;
healthError: string | null;
};
/**
* Fetch the gateway health summary.
*
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
* convention). Returns a fully-typed {@link HealthSummary}; on failure the
* caller receives a safe fallback with `ok: false` rather than `null`.
*/
export async function loadHealth(client: GatewayBrowserClient): Promise<HealthSummary> {
try {
const result = await client.request<HealthSummary>("health", {});
return result ?? HEALTH_FALLBACK;
} catch {
return HEALTH_FALLBACK;
}
}
/**
* State-mutating health loader (same pattern as {@link import("./agents.ts").loadAgents}).
*
* Populates `healthResult` / `healthError` on the provided state slice and
* toggles `healthLoading` around the request.
*/
export async function loadHealthState(state: HealthState): Promise<void> {
if (!state.client || !state.connected) {
return;
}
if (state.healthLoading) {
return;
}
state.healthLoading = true;
state.healthError = null;
try {
state.healthResult = await loadHealth(state.client);
} catch (err) {
state.healthError = String(err);
} finally {
state.healthLoading = false;
}
}

View File

@@ -0,0 +1,18 @@
import type { GatewayBrowserClient } from "../gateway.ts";
import type { ModelCatalogEntry } from "../types.ts";
/**
* Fetch the model catalog from the gateway.
*
* Accepts a {@link GatewayBrowserClient} (matching the existing ui/ controller
* convention). Returns an array of {@link ModelCatalogEntry}; on failure the
* caller receives an empty array rather than throwing.
*/
export async function loadModels(client: GatewayBrowserClient): Promise<ModelCatalogEntry[]> {
try {
const result = await client.request<{ models: ModelCatalogEntry[] }>("models.list", {});
return result?.models ?? [];
} catch {
return [];
}
}

View File

@@ -7,8 +7,11 @@ const allowedTags = [
"b", "b",
"blockquote", "blockquote",
"br", "br",
"button",
"code", "code",
"del", "del",
"details",
"div",
"em", "em",
"h1", "h1",
"h2", "h2",
@@ -20,7 +23,9 @@ const allowedTags = [
"ol", "ol",
"p", "p",
"pre", "pre",
"span",
"strong", "strong",
"summary",
"table", "table",
"tbody", "tbody",
"td", "td",
@@ -31,7 +36,19 @@ const allowedTags = [
"img", "img",
]; ];
const allowedAttrs = ["class", "href", "rel", "target", "title", "start", "src", "alt"]; const allowedAttrs = [
"class",
"href",
"rel",
"target",
"title",
"start",
"src",
"alt",
"data-code",
"type",
"aria-label",
];
const sanitizeOptions = { const sanitizeOptions = {
ALLOWED_TAGS: allowedTags, ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs, ALLOWED_ATTR: allowedAttrs,
@@ -45,6 +62,7 @@ const MARKDOWN_CACHE_LIMIT = 200;
const MARKDOWN_CACHE_MAX_CHARS = 50_000; const MARKDOWN_CACHE_MAX_CHARS = 50_000;
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i; const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
const markdownCache = new Map<string, string>(); const markdownCache = new Map<string, string>();
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
function getCachedMarkdown(key: string): string | null { function getCachedMarkdown(key: string): string | null {
const cached = markdownCache.get(key); const cached = markdownCache.get(key);
@@ -83,6 +101,9 @@ function installHooks() {
} }
node.setAttribute("rel", "noreferrer noopener"); node.setAttribute("rel", "noreferrer noopener");
node.setAttribute("target", "_blank"); node.setAttribute("target", "_blank");
if (href.toLowerCase().includes("tail")) {
node.classList.add(TAIL_LINK_BLUR_CLASS);
}
}); });
} }
@@ -152,6 +173,43 @@ function normalizeMarkdownImageLabel(text?: string | null): string {
return trimmed ? trimmed : "image"; return trimmed ? trimmed : "image";
} }
htmlEscapeRenderer.code = ({
text,
lang,
escaped,
}: {
text: string;
lang?: string;
escaped?: boolean;
}) => {
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
const safeText = escaped ? text : escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
const attrSafe = text
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button>`;
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
const trimmed = text.trim();
const isJson =
lang === "json" ||
(!lang &&
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
if (isJson) {
const lineCount = text.split("\n").length;
const label = lineCount > 1 ? `JSON &middot; ${lineCount} lines` : "JSON";
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
}
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
};
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")

View File

@@ -10,7 +10,16 @@ export const TAB_GROUPS = [
{ label: "agent", tabs: ["agents", "skills", "nodes"] }, { label: "agent", tabs: ["agents", "skills", "nodes"] },
{ {
label: "settings", label: "settings",
tabs: ["config", "debug", "logs"], tabs: [
"config",
"communications",
"appearance",
"automation",
"infrastructure",
"aiAgents",
"debug",
"logs",
],
}, },
] as const; ] as const;
@@ -55,19 +64,7 @@ const TAB_PATHS: Record<Tab, string> = {
logs: "/logs", logs: "/logs",
}; };
const HIDDEN_SETTINGS_TABS = new Set<Tab>([ const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
"communications",
"appearance",
"automation",
"infrastructure",
"aiAgents",
]);
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS)
.filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab))
.map(([tab, path]) => [path, tab as Tab]),
);
export function normalizeBasePath(basePath: string): string { export function normalizeBasePath(basePath: string): string {
if (!basePath) { if (!basePath) {

View File

@@ -19,11 +19,40 @@ export type UiSettings = {
chatShowThinking: boolean; chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (200400px) navWidth: number; // Sidebar width when expanded (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
locale?: string; locale?: string;
}; };
function isViteDevPage(): boolean {
if (typeof document === "undefined") {
return false;
}
return Boolean(document.querySelector('script[src*="/@vite/client"]'));
}
function formatHostWithPort(hostname: string, port: string): string {
const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
return `${normalizedHost}:${port}`;
}
function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
const proto = location.protocol === "https:" ? "wss" : "ws";
const configured =
typeof window !== "undefined" &&
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
const basePath = configured
? normalizeBasePath(configured)
: inferBasePathFromPathname(location.pathname);
const pageUrl = `${proto}://${location.host}${basePath}`;
if (!isViteDevPage()) {
return { pageUrl, effectiveUrl: pageUrl };
}
const effectiveUrl = `${proto}://${formatHostWithPort(location.hostname, "18789")}`;
return { pageUrl, effectiveUrl };
}
function getSessionStorage(): Storage | null { function getSessionStorage(): Storage | null {
if (typeof window !== "undefined" && window.sessionStorage) { if (typeof window !== "undefined" && window.sessionStorage) {
return window.sessionStorage; return window.sessionStorage;
@@ -91,17 +120,7 @@ function persistSessionToken(gatewayUrl: string, token: string) {
} }
export function loadSettings(): UiSettings { export function loadSettings(): UiSettings {
const defaultUrl = (() => { const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
const proto = location.protocol === "https:" ? "wss" : "ws";
const configured =
typeof window !== "undefined" &&
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
const basePath = configured
? normalizeBasePath(configured)
: inferBasePathFromPathname(location.pathname);
return `${proto}://${location.host}${basePath}`;
})();
const defaults: UiSettings = { const defaults: UiSettings = {
gatewayUrl: defaultUrl, gatewayUrl: defaultUrl,
@@ -124,21 +143,19 @@ export function loadSettings(): UiSettings {
return defaults; return defaults;
} }
const parsed = JSON.parse(raw) as Partial<UiSettings>; const parsed = JSON.parse(raw) as Partial<UiSettings>;
const parsedGatewayUrl =
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl;
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
const { theme, mode } = parseThemeSelection( const { theme, mode } = parseThemeSelection(
(parsed as { theme?: unknown }).theme, (parsed as { theme?: unknown }).theme,
(parsed as { themeMode?: unknown }).themeMode, (parsed as { themeMode?: unknown }).themeMode,
); );
const settings = { const settings = {
gatewayUrl: gatewayUrl,
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load. // Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
token: loadSessionToken( token: loadSessionToken(gatewayUrl),
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
),
sessionKey: sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim() typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim() ? parsed.sessionKey.trim()

View File

@@ -62,42 +62,13 @@ function resolveMode(mode: ThemeMode): "light" | "dark" {
return mode; return mode;
} }
function normalizeThemeArgs( export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
themeOrMode: ThemeName | ThemeMode, const resolvedMode = resolveMode(mode);
mode: ThemeMode | undefined, if (theme === "claw") {
): { theme: ThemeName; mode: ThemeMode } {
if (VALID_THEME_NAMES.has(themeOrMode as ThemeName)) {
return {
theme: themeOrMode as ThemeName,
mode: mode ?? "system",
};
}
return {
theme: "claw",
mode: themeOrMode as ThemeMode,
};
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme;
export function resolveTheme(theme: ThemeName, mode?: ThemeMode): ResolvedTheme;
export function resolveTheme(themeOrMode: ThemeName | ThemeMode, mode?: ThemeMode): ResolvedTheme {
const normalized = normalizeThemeArgs(themeOrMode, mode);
const resolvedMode = resolveMode(normalized.mode);
if (normalized.theme === "claw") {
return resolvedMode === "light" ? "light" : "dark"; return resolvedMode === "light" ? "light" : "dark";
} }
if (normalized.theme === "knot") { if (theme === "knot") {
return resolvedMode === "light" ? "openknot-light" : "openknot"; return resolvedMode === "light" ? "openknot-light" : "openknot";
} }
return resolvedMode === "light" ? "dash-light" : "dash"; return resolvedMode === "light" ? "dash-light" : "dash";
} }
export function colorSchemeForTheme(theme: ResolvedTheme): "light" | "dark" {
return theme === "light" || theme === "openknot-light" || theme === "dash-light"
? "light"
: "dark";
}
export function dataThemeForTheme(theme: ResolvedTheme): ResolvedTheme | "light" {
return colorSchemeForTheme(theme) === "light" ? "light" : theme;
}

View File

@@ -411,6 +411,15 @@ export type {
SessionUsageTimeSeries, SessionUsageTimeSeries,
} from "./usage-types.ts"; } from "./usage-types.ts";
export type CronRunStatus = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
export type CronRunScope = "job" | "all";
export type CronRunsStatusValue = CronRunStatus;
export type CronRunsStatusFilter = "all" | CronRunStatus;
export type CronSortDir = "asc" | "desc";
export type CronSchedule = export type CronSchedule =
| { kind: "at"; at: string } | { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "every"; everyMs: number; anchorMs?: number }
@@ -425,9 +434,15 @@ export type CronPayload =
kind: "agentTurn"; kind: "agentTurn";
message: string; message: string;
model?: string; model?: string;
fallbacks?: string[];
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
allowUnsafeExternalContent?: boolean;
lightContext?: boolean; lightContext?: boolean;
deliver?: boolean;
channel?: string;
to?: string;
bestEffortDeliver?: boolean;
}; };
export type CronDelivery = { export type CronDelivery = {
@@ -459,9 +474,15 @@ export type CronJobState = {
nextRunAtMs?: number; nextRunAtMs?: number;
runningAtMs?: number; runningAtMs?: number;
lastRunAtMs?: number; lastRunAtMs?: number;
lastStatus?: "ok" | "error" | "skipped"; lastRunStatus?: CronRunStatus;
lastStatus?: CronRunStatus;
lastError?: string; lastError?: string;
lastErrorReason?: string;
lastDurationMs?: number; lastDurationMs?: number;
consecutiveErrors?: number;
lastDelivered?: boolean;
lastDeliveryStatus?: CronDeliveryStatus;
lastDeliveryError?: string;
lastFailureAlertAtMs?: number; lastFailureAlertAtMs?: number;
}; };
@@ -482,25 +503,19 @@ export type CronStatus = {
nextWakeAtMs?: number | null; nextWakeAtMs?: number | null;
}; };
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
export type CronSortDir = "asc" | "desc";
export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped";
export type CronRunsStatusValue = "ok" | "error" | "skipped";
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
export type CronRunScope = "job" | "all";
export type CronRunLogEntry = { export type CronRunLogEntry = {
ts: number; ts: number;
jobId: string; jobId: string;
jobName?: string; action?: "finished";
status?: CronRunsStatusValue; status?: CronRunStatus;
durationMs?: number; durationMs?: number;
error?: string; error?: string;
summary?: string; summary?: string;
delivered?: boolean;
deliveryStatus?: CronDeliveryStatus; deliveryStatus?: CronDeliveryStatus;
deliveryError?: string; deliveryError?: string;
delivered?: boolean; sessionId?: string;
sessionKey?: string;
runAtMs?: number; runAtMs?: number;
nextRunAtMs?: number; nextRunAtMs?: number;
model?: string; model?: string;
@@ -512,26 +527,25 @@ export type CronRunLogEntry = {
cache_read_tokens?: number; cache_read_tokens?: number;
cache_write_tokens?: number; cache_write_tokens?: number;
}; };
sessionId?: string; jobName?: string;
sessionKey?: string;
}; };
export type CronJobsListResult = { export type CronJobsListResult = {
jobs?: CronJob[]; jobs: CronJob[];
total?: number; total?: number;
offset?: number;
limit?: number; limit?: number;
hasMore?: boolean; offset?: number;
nextOffset?: number | null; nextOffset?: number | null;
hasMore?: boolean;
}; };
export type CronRunsResult = { export type CronRunsResult = {
entries?: CronRunLogEntry[]; entries: CronRunLogEntry[];
total?: number; total?: number;
offset?: number;
limit?: number; limit?: number;
hasMore?: boolean; offset?: number;
nextOffset?: number | null; nextOffset?: number | null;
hasMore?: boolean;
}; };
export type SkillsStatusConfigCheck = { export type SkillsStatusConfigCheck = {

View File

@@ -10,6 +10,8 @@ export type ChatQueueItem = {
createdAt: number; createdAt: number;
attachments?: ChatAttachment[]; attachments?: ChatAttachment[];
refreshSessions?: boolean; refreshSessions?: boolean;
localCommandArgs?: string;
localCommandName?: string;
}; };
export const CRON_CHANNEL_LAST = "last"; export const CRON_CHANNEL_LAST = "last";

View File

@@ -0,0 +1,195 @@
import { html, nothing } from "lit";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
import {
buildModelOptions,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
import type { AgentsPanel } from "./agents.ts";
export function renderAgentOverview(params: {
agent: AgentsListResult["agents"][number];
basePath: string;
defaultId: string | null;
configForm: Record<string, unknown> | null;
agentFilesList: AgentsFilesListResult | null;
agentIdentity: AgentIdentityResult | null;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onConfigReload: () => void;
onConfigSave: () => void;
onModelChange: (agentId: string, modelId: string | null) => void;
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
onSelectPanel: (panel: AgentsPanel) => void;
}) {
const {
agent,
configForm,
agentFilesList,
configLoading,
configSaving,
configDirty,
onConfigReload,
onConfigSave,
onModelChange,
onModelFallbacksChange,
onSelectPanel,
} = params;
const config = resolveAgentConfig(configForm, agent.id);
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
const model = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const defaultModel = resolveModelLabel(config.defaults?.model);
const entryPrimary = resolveModelPrimary(config.entry?.model);
const defaultPrimary =
resolveModelPrimary(config.defaults?.model) ||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
const effectivePrimary = entryPrimary ?? defaultPrimary ?? null;
const modelFallbacks = resolveModelFallbacks(config.entry?.model);
const fallbackChips = modelFallbacks ?? [];
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
const isDefault = Boolean(params.defaultId && agent.id === params.defaultId);
const disabled = !configForm || configLoading || configSaving;
const removeChip = (index: number) => {
const next = fallbackChips.filter((_, i) => i !== index);
onModelFallbacksChange(agent.id, next);
};
const handleChipKeydown = (e: KeyboardEvent) => {
const input = e.target as HTMLInputElement;
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const parsed = parseFallbackList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";
}
}
};
return html`
<section class="card">
<div class="card-title">Overview</div>
<div class="card-sub">Workspace paths and identity metadata.</div>
<div class="agents-overview-grid" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Workspace</div>
<div>
<button
type="button"
class="workspace-link mono"
@click=${() => onSelectPanel("files")}
title="Open Files tab"
>${workspace}</button>
</div>
</div>
<div class="agent-kv">
<div class="label">Primary Model</div>
<div class="mono">${model}</div>
</div>
<div class="agent-kv">
<div class="label">Skills Filter</div>
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
</div>
</div>
${
configDirty
? html`
<div class="callout warn" style="margin-top: 16px">You have unsaved config changes.</div>
`
: nothing
}
<div class="agent-model-select" style="margin-top: 20px;">
<div class="label">Model Selection</div>
<div class="agent-model-fields">
<label class="field">
<span>Primary model${isDefault ? " (default)" : ""}</span>
<select
.value=${isDefault ? (effectivePrimary ?? "") : (entryPrimary ?? "")}
?disabled=${disabled}
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? nothing
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
</select>
</label>
<div class="field">
<span>Fallbacks</span>
<div class="agent-chip-input" @click=${(e: Event) => {
const container = e.currentTarget as HTMLElement;
const input = container.querySelector("input");
if (input) {
input.focus();
}
}}>
${fallbackChips.map(
(chip, i) => html`
<span class="chip">
${chip}
<button
type="button"
class="chip-remove"
?disabled=${disabled}
@click=${() => removeChip(i)}
>&times;</button>
</span>
`,
)}
<input
?disabled=${disabled}
placeholder=${fallbackChips.length === 0 ? "provider/model" : ""}
@keydown=${handleChipKeydown}
@blur=${(e: Event) => {
const input = e.target as HTMLInputElement;
const parsed = parseFallbackList(input.value);
if (parsed.length > 0) {
onModelFallbacksChange(agent.id, [...fallbackChips, ...parsed]);
input.value = "";
}
}}
/>
</div>
</div>
</div>
<div class="agent-model-actions">
<button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
Reload Config
</button>
<button
type="button"
class="btn btn--sm primary"
?disabled=${configSaving || !configDirty}
@click=${onConfigSave}
>
${configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
`;
}

View File

@@ -1,5 +1,8 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { formatRelativeTimestamp } from "../format.ts"; import { formatRelativeTimestamp } from "../format.ts";
import { icons } from "../icons.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
import { import {
formatCronPayload, formatCronPayload,
formatCronSchedule, formatCronSchedule,
@@ -36,8 +39,8 @@ function renderAgentContextCard(context: AgentContext, subtitle: string) {
<div>${context.identityName}</div> <div>${context.identityName}</div>
</div> </div>
<div class="agent-kv"> <div class="agent-kv">
<div class="label">Identity Emoji</div> <div class="label">Identity Avatar</div>
<div>${context.identityEmoji}</div> <div>${context.identityAvatar}</div>
</div> </div>
<div class="agent-kv"> <div class="agent-kv">
<div class="label">Skills Filter</div> <div class="label">Skills Filter</div>
@@ -182,7 +185,7 @@ export function renderAgentChannels(params: {
const status = summary.total const status = summary.total
? `${summary.connected}/${summary.total} connected` ? `${summary.connected}/${summary.total} connected`
: "no accounts"; : "no accounts";
const config = summary.configured const configLabel = summary.configured
? `${summary.configured} configured` ? `${summary.configured} configured`
: "not configured"; : "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled"; const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
@@ -199,8 +202,23 @@ export function renderAgentChannels(params: {
</div> </div>
<div class="list-meta"> <div class="list-meta">
<div>${status}</div> <div>${status}</div>
<div>${config}</div> <div>${configLabel}</div>
<div>${enabled}</div> <div>${enabled}</div>
${
summary.configured === 0
? html`
<div>
<a
href="https://docs.openclaw.ai/channels"
target="_blank"
rel="noopener"
style="color: var(--accent); font-size: 12px"
>Setup guide</a
>
</div>
`
: nothing
}
${ ${
extras.length > 0 extras.length > 0
? extras.map( ? extras.map(
@@ -228,6 +246,7 @@ export function renderAgentCron(params: {
loading: boolean; loading: boolean;
error: string | null; error: string | null;
onRefresh: () => void; onRefresh: () => void;
onRunNow: (jobId: string) => void;
}) { }) {
const jobs = params.jobs.filter((job) => job.agentId === params.agentId); const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
return html` return html`
@@ -297,6 +316,12 @@ export function renderAgentCron(params: {
<div class="list-meta"> <div class="list-meta">
<div class="mono">${formatCronState(job)}</div> <div class="mono">${formatCronState(job)}</div>
<div class="muted">${formatCronPayload(job)}</div> <div class="muted">${formatCronPayload(job)}</div>
<button
class="btn btn--sm"
style="margin-top: 6px;"
?disabled=${!job.enabled}
@click=${() => params.onRunNow(job.id)}
>Run Now</button>
</div> </div>
</div> </div>
`, `,
@@ -389,6 +414,21 @@ export function renderAgentFiles(params: {
<div class="agent-file-sub mono">${activeEntry.path}</div> <div class="agent-file-sub mono">${activeEntry.path}</div>
</div> </div>
<div class="agent-file-actions"> <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 <button
class="btn btn--sm" class="btn btn--sm"
?disabled=${!isDirty} ?disabled=${!isDirty}
@@ -414,9 +454,10 @@ export function renderAgentFiles(params: {
` `
: nothing : nothing
} }
<label class="field" style="margin-top: 12px;"> <label class="field agent-file-field" style="margin-top: 12px;">
<span>Content</span> <span>Content</span>
<textarea <textarea
class="agent-file-textarea"
.value=${draft} .value=${draft}
@input=${(e: Event) => @input=${(e: Event) =>
params.onFileDraftChange( params.onFileDraftChange(
@@ -425,6 +466,30 @@ export function renderAgentFiles(params: {
)} )}
></textarea> ></textarea>
</label> </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>
<button
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>${icons.x} Close</button>
</div>
<div class="md-preview-dialog__body sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(draft))}
</div>
</div>
</dialog>
` `
} }
</div> </div>

View File

@@ -2,12 +2,14 @@ import { html, nothing } from "lit";
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js"; import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts"; import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
import { import {
type AgentToolEntry,
type AgentToolSection,
isAllowedByPolicy, isAllowedByPolicy,
matchesList, matchesList,
PROFILE_OPTIONS,
resolveAgentConfig, resolveAgentConfig,
resolveToolProfileOptions,
resolveToolProfile, resolveToolProfile,
TOOL_SECTIONS, resolveToolSections,
} from "./agents-utils.ts"; } from "./agents-utils.ts";
import type { SkillGroup } from "./skills-grouping.ts"; import type { SkillGroup } from "./skills-grouping.ts";
import { groupSkills } from "./skills-grouping.ts"; import { groupSkills } from "./skills-grouping.ts";
@@ -17,6 +19,28 @@ import {
renderSkillStatusChips, renderSkillStatusChips,
} from "./skills-shared.ts"; } from "./skills-shared.ts";
function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) {
const source = tool.source ?? section.source;
const pluginId = tool.pluginId ?? section.pluginId;
const badges: string[] = [];
if (source === "plugin" && pluginId) {
badges.push(`plugin:${pluginId}`);
} else if (source === "core") {
badges.push("core");
}
if (tool.optional) {
badges.push("optional");
}
if (badges.length === 0) {
return nothing;
}
return html`
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
${badges.map((badge) => html`<span class="agent-pill">${badge}</span>`)}
</div>
`;
}
export function renderAgentTools(params: { export function renderAgentTools(params: {
agentId: string; agentId: string;
configForm: Record<string, unknown> | null; configForm: Record<string, unknown> | null;
@@ -35,6 +59,8 @@ export function renderAgentTools(params: {
const agentTools = config.entry?.tools ?? {}; const agentTools = config.entry?.tools ?? {};
const globalTools = config.globalTools ?? {}; const globalTools = config.globalTools ?? {};
const profile = agentTools.profile ?? globalTools.profile ?? "full"; const profile = agentTools.profile ?? globalTools.profile ?? "full";
const profileOptions = resolveToolProfileOptions(params.toolsCatalogResult);
const toolSections = resolveToolSections(params.toolsCatalogResult);
const profileSource = agentTools.profile const profileSource = agentTools.profile
? "agent override" ? "agent override"
: globalTools.profile : globalTools.profile
@@ -43,7 +69,11 @@ export function renderAgentTools(params: {
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0; const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0; const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
const editable = const editable =
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow; Boolean(params.configForm) &&
!params.configLoading &&
!params.configSaving &&
!hasAgentAllow &&
!(params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError);
const alsoAllow = hasAgentAllow const alsoAllow = hasAgentAllow
? [] ? []
: Array.isArray(agentTools.alsoAllow) : Array.isArray(agentTools.alsoAllow)
@@ -53,17 +83,7 @@ export function renderAgentTools(params: {
const basePolicy = hasAgentAllow const basePolicy = hasAgentAllow
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] } ? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
: (resolveToolProfile(profile) ?? undefined); : (resolveToolProfile(profile) ?? undefined);
const sections = const toolIds = toolSections.flatMap((section) => section.tools.map((tool) => tool.id));
params.toolsCatalogResult?.groups?.length &&
params.toolsCatalogResult.agentId === params.agentId
? params.toolsCatalogResult.groups
: TOOL_SECTIONS;
const profileOptions =
params.toolsCatalogResult?.profiles?.length &&
params.toolsCatalogResult.agentId === params.agentId
? params.toolsCatalogResult.profiles
: PROFILE_OPTIONS;
const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id));
const resolveAllowed = (toolId: string) => { const resolveAllowed = (toolId: string) => {
const baseAllowed = isAllowedByPolicy(toolId, basePolicy); const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
@@ -152,15 +172,6 @@ export function renderAgentTools(params: {
</div> </div>
</div> </div>
${
params.toolsCatalogError
? html`
<div class="callout warn" style="margin-top: 12px">
Could not load runtime tool catalog. Showing fallback list.
</div>
`
: nothing
}
${ ${
!params.configForm !params.configForm
? html` ? html`
@@ -188,6 +199,22 @@ export function renderAgentTools(params: {
` `
: nothing : nothing
} }
${
params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">Loading runtime tool catalog…</div>
`
: nothing
}
${
params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load runtime tool catalog. Showing built-in fallback list instead.
</div>
`
: nothing
}
<div class="agent-tools-meta" style="margin-top: 16px;"> <div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv"> <div class="agent-kv">
@@ -235,50 +262,27 @@ export function renderAgentTools(params: {
</div> </div>
<div class="agent-tools-grid" style="margin-top: 20px;"> <div class="agent-tools-grid" style="margin-top: 20px;">
${sections.map( ${toolSections.map(
(section) => (section) =>
html` html`
<div class="agent-tools-section"> <div class="agent-tools-section">
<div class="agent-tools-header"> <div class="agent-tools-header">
${section.label} ${section.label}
${ ${
"source" in section && section.source === "plugin" section.source === "plugin" && section.pluginId
? html` ? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
<span class="mono" style="margin-left: 6px">plugin</span>
`
: nothing : nothing
} }
</div> </div>
<div class="agent-tools-list"> <div class="agent-tools-list">
${section.tools.map((tool) => { ${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id); const { allowed } = resolveAllowed(tool.id);
const catalogTool = tool as {
source?: "core" | "plugin";
pluginId?: string;
optional?: boolean;
};
const source =
catalogTool.source === "plugin"
? catalogTool.pluginId
? `plugin:${catalogTool.pluginId}`
: "plugin"
: "core";
const isOptional = catalogTool.optional === true;
return html` return html`
<div class="agent-tool-row"> <div class="agent-tool-row">
<div> <div>
<div class="agent-tool-title mono"> <div class="agent-tool-title mono">${tool.label}</div>
${tool.label}
<span class="mono" style="margin-left: 8px; opacity: 0.8;">${source}</span>
${
isOptional
? html`
<span class="mono" style="margin-left: 6px; opacity: 0.8">optional</span>
`
: nothing
}
</div>
<div class="agent-tool-sub">${tool.description}</div> <div class="agent-tool-sub">${tool.description}</div>
${renderToolBadges(section, tool)}
</div> </div>
<label class="cfg-toggle"> <label class="cfg-toggle">
<input <input
@@ -298,13 +302,6 @@ export function renderAgentTools(params: {
`, `,
)} )}
</div> </div>
${
params.toolsCatalogLoading
? html`
<div class="card-sub" style="margin-top: 10px">Refreshing tool catalog…</div>
`
: nothing
}
</section> </section>
`; `;
} }
@@ -361,17 +358,27 @@ export function renderAgentSkills(params: {
} }
</div> </div>
</div> </div>
<div class="row" style="gap: 8px;"> <div class="row" style="gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}> <div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
Use All <button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
</button> Enable All
<button </button>
class="btn btn--sm" <button
?disabled=${!editable} class="btn btn--sm"
@click=${() => params.onDisableAll(params.agentId)} ?disabled=${!editable}
> @click=${() => params.onDisableAll(params.agentId)}
Disable All >
</button> Disable All
</button>
<button
class="btn btn--sm"
?disabled=${!editable || !usingAllowlist}
@click=${() => params.onClear(params.agentId)}
title="Remove per-agent allowlist and use all skills"
>
Reset
</button>
</div>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}> <button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
Reload Config Reload Config
</button> </button>

View File

@@ -1,18 +1,157 @@
import { html } from "lit"; import { html } from "lit";
import {
listCoreToolSections,
PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS,
} from "../../../../src/agents/tool-catalog.js";
import { import {
expandToolGroups, expandToolGroups,
normalizeToolName, normalizeToolName,
resolveToolProfilePolicy, resolveToolProfilePolicy,
} from "../../../../src/agents/tool-policy-shared.js"; } from "../../../../src/agents/tool-policy-shared.js";
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts"; import type {
AgentIdentityResult,
AgentsFilesListResult,
AgentsListResult,
ToolCatalogProfile,
ToolsCatalogResult,
} from "../types.ts";
export const TOOL_SECTIONS = listCoreToolSections(); export type AgentToolEntry = {
id: string;
label: string;
description: string;
source?: "core" | "plugin";
pluginId?: string;
optional?: boolean;
defaultProfiles?: string[];
};
export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS; export type AgentToolSection = {
id: string;
label: string;
source?: "core" | "plugin";
pluginId?: string;
tools: AgentToolEntry[];
};
export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [
{
id: "fs",
label: "Files",
tools: [
{ id: "read", label: "read", description: "Read file contents" },
{ id: "write", label: "write", description: "Create or overwrite files" },
{ id: "edit", label: "edit", description: "Make precise edits" },
{ id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" },
],
},
{
id: "runtime",
label: "Runtime",
tools: [
{ id: "exec", label: "exec", description: "Run shell commands" },
{ id: "process", label: "process", description: "Manage background processes" },
],
},
{
id: "web",
label: "Web",
tools: [
{ id: "web_search", label: "web_search", description: "Search the web" },
{ id: "web_fetch", label: "web_fetch", description: "Fetch web content" },
],
},
{
id: "memory",
label: "Memory",
tools: [
{ id: "memory_search", label: "memory_search", description: "Semantic search" },
{ id: "memory_get", label: "memory_get", description: "Read memory files" },
],
},
{
id: "sessions",
label: "Sessions",
tools: [
{ id: "sessions_list", label: "sessions_list", description: "List sessions" },
{ id: "sessions_history", label: "sessions_history", description: "Session history" },
{ id: "sessions_send", label: "sessions_send", description: "Send to session" },
{ id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" },
{ id: "session_status", label: "session_status", description: "Session status" },
],
},
{
id: "ui",
label: "UI",
tools: [
{ id: "browser", label: "browser", description: "Control web browser" },
{ id: "canvas", label: "canvas", description: "Control canvases" },
],
},
{
id: "messaging",
label: "Messaging",
tools: [{ id: "message", label: "message", description: "Send messages" }],
},
{
id: "automation",
label: "Automation",
tools: [
{ id: "cron", label: "cron", description: "Schedule tasks" },
{ id: "gateway", label: "gateway", description: "Gateway control" },
],
},
{
id: "nodes",
label: "Nodes",
tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }],
},
{
id: "agents",
label: "Agents",
tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }],
},
{
id: "media",
label: "Media",
tools: [{ id: "image", label: "image", description: "Image understanding" }],
},
];
export const PROFILE_OPTIONS = [
{ id: "minimal", label: "Minimal" },
{ id: "coding", label: "Coding" },
{ id: "messaging", label: "Messaging" },
{ id: "full", label: "Full" },
] as const;
export function resolveToolSections(
toolsCatalogResult: ToolsCatalogResult | null,
): AgentToolSection[] {
if (toolsCatalogResult?.groups?.length) {
return toolsCatalogResult.groups.map((group) => ({
id: group.id,
label: group.label,
source: group.source,
pluginId: group.pluginId,
tools: group.tools.map((tool) => ({
id: tool.id,
label: tool.label,
description: tool.description,
source: tool.source,
pluginId: tool.pluginId,
optional: tool.optional,
defaultProfiles: [...tool.defaultProfiles],
})),
}));
}
return FALLBACK_TOOL_SECTIONS;
}
export function resolveToolProfileOptions(
toolsCatalogResult: ToolsCatalogResult | null,
): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS {
if (toolsCatalogResult?.profiles?.length) {
return toolsCatalogResult.profiles;
}
return PROFILE_OPTIONS;
}
type ToolPolicy = { type ToolPolicy = {
allow?: string[]; allow?: string[];
@@ -55,6 +194,30 @@ export function normalizeAgentLabel(agent: {
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id; return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
} }
const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i;
export function resolveAgentAvatarUrl(
agent: { identity?: { avatar?: string; avatarUrl?: string } },
agentIdentity?: AgentIdentityResult | null,
): string | null {
const url =
agentIdentity?.avatar?.trim() ??
agent.identity?.avatarUrl?.trim() ??
agent.identity?.avatar?.trim();
if (!url) {
return null;
}
if (AVATAR_URL_RE.test(url)) {
return url;
}
return null;
}
export function agentLogoUrl(basePath: string): string {
const base = basePath?.trim() ? basePath.replace(/\/$/, "") : "";
return base ? `${base}/favicon.svg` : "/favicon.svg";
}
function isLikelyEmoji(value: string) { function isLikelyEmoji(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) { if (!trimmed) {
@@ -106,6 +269,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) {
return defaultId && agentId === defaultId ? "default" : null; return defaultId && agentId === defaultId ? "default" : null;
} }
export function agentAvatarHue(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i += 1) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return ((hash % 360) + 360) % 360;
}
export function formatBytes(bytes?: number) { export function formatBytes(bytes?: number) {
if (bytes == null || !Number.isFinite(bytes)) { if (bytes == null || !Number.isFinite(bytes)) {
return "-"; return "-";
@@ -138,7 +309,7 @@ export type AgentContext = {
workspace: string; workspace: string;
model: string; model: string;
identityName: string; identityName: string;
identityEmoji: string; identityAvatar: string;
skillsLabel: string; skillsLabel: string;
isDefault: boolean; isDefault: boolean;
}; };
@@ -164,14 +335,14 @@ export function buildAgentContext(
agent.name?.trim() || agent.name?.trim() ||
config.entry?.name || config.entry?.name ||
agent.id; agent.id;
const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-"; const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—";
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null; const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null; const skillCount = skillFilter?.length ?? null;
return { return {
workspace, workspace,
model: modelLabel, model: modelLabel,
identityName, identityName,
identityEmoji, identityAvatar,
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills", skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
isDefault: Boolean(defaultId && agent.id === defaultId), isDefault: Boolean(defaultId && agent.id === defaultId),
}; };

View File

@@ -9,64 +9,78 @@ import type {
SkillStatusReport, SkillStatusReport,
ToolsCatalogResult, ToolsCatalogResult,
} from "../types.ts"; } from "../types.ts";
import { renderAgentOverview } from "./agents-panels-overview.ts";
import { import {
renderAgentFiles, renderAgentFiles,
renderAgentChannels, renderAgentChannels,
renderAgentCron, renderAgentCron,
} from "./agents-panels-status-files.ts"; } from "./agents-panels-status-files.ts";
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts"; import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
import { import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
agentBadgeText,
buildAgentContext,
buildModelOptions,
normalizeAgentLabel,
normalizeModelValue,
parseFallbackList,
resolveAgentConfig,
resolveAgentEmoji,
resolveEffectiveModelFallbacks,
resolveModelLabel,
resolveModelPrimary,
} from "./agents-utils.ts";
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron"; export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
export type ConfigState = {
form: Record<string, unknown> | null;
loading: boolean;
saving: boolean;
dirty: boolean;
};
export type ChannelsState = {
snapshot: ChannelsStatusSnapshot | null;
loading: boolean;
error: string | null;
lastSuccess: number | null;
};
export type CronState = {
status: CronStatus | null;
jobs: CronJob[];
loading: boolean;
error: string | null;
};
export type AgentFilesState = {
list: AgentsFilesListResult | null;
loading: boolean;
error: string | null;
active: string | null;
contents: Record<string, string>;
drafts: Record<string, string>;
saving: boolean;
};
export type AgentSkillsState = {
report: SkillStatusReport | null;
loading: boolean;
error: string | null;
agentId: string | null;
filter: string;
};
export type ToolsCatalogState = {
loading: boolean;
error: string | null;
result: ToolsCatalogResult | null;
};
export type AgentsProps = { export type AgentsProps = {
basePath: string;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
agentsList: AgentsListResult | null; agentsList: AgentsListResult | null;
selectedAgentId: string | null; selectedAgentId: string | null;
activePanel: AgentsPanel; activePanel: AgentsPanel;
configForm: Record<string, unknown> | null; config: ConfigState;
configLoading: boolean; channels: ChannelsState;
configSaving: boolean; cron: CronState;
configDirty: boolean; agentFiles: AgentFilesState;
channelsLoading: boolean;
channelsError: string | null;
channelsSnapshot: ChannelsStatusSnapshot | null;
channelsLastSuccess: number | null;
cronLoading: boolean;
cronStatus: CronStatus | null;
cronJobs: CronJob[];
cronError: string | null;
agentFilesLoading: boolean;
agentFilesError: string | null;
agentFilesList: AgentsFilesListResult | null;
agentFileActive: string | null;
agentFileContents: Record<string, string>;
agentFileDrafts: Record<string, string>;
agentFileSaving: boolean;
agentIdentityLoading: boolean; agentIdentityLoading: boolean;
agentIdentityError: string | null; agentIdentityError: string | null;
agentIdentityById: Record<string, AgentIdentityResult>; agentIdentityById: Record<string, AgentIdentityResult>;
agentSkillsLoading: boolean; agentSkills: AgentSkillsState;
agentSkillsReport: SkillStatusReport | null; toolsCatalog: ToolsCatalogState;
agentSkillsError: string | null;
agentSkillsAgentId: string | null;
toolsCatalogLoading: boolean;
toolsCatalogError: string | null;
toolsCatalogResult: ToolsCatalogResult | null;
skillsFilter: string;
onRefresh: () => void; onRefresh: () => void;
onSelectAgent: (agentId: string) => void; onSelectAgent: (agentId: string) => void;
onSelectPanel: (panel: AgentsPanel) => void; onSelectPanel: (panel: AgentsPanel) => void;
@@ -83,20 +97,13 @@ export type AgentsProps = {
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void; onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
onChannelsRefresh: () => void; onChannelsRefresh: () => void;
onCronRefresh: () => void; onCronRefresh: () => void;
onCronRunNow: (jobId: string) => void;
onSkillsFilterChange: (next: string) => void; onSkillsFilterChange: (next: string) => void;
onSkillsRefresh: () => void; onSkillsRefresh: () => void;
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void; onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
onAgentSkillsClear: (agentId: string) => void; onAgentSkillsClear: (agentId: string) => void;
onAgentSkillsDisableAll: (agentId: string) => void; onAgentSkillsDisableAll: (agentId: string) => void;
}; onSetDefault: (agentId: string) => void;
export type AgentContext = {
workspace: string;
model: string;
identityName: string;
identityEmoji: string;
skillsLabel: string;
isDefault: boolean;
}; };
export function renderAgents(props: AgentsProps) { export function renderAgents(props: AgentsProps) {
@@ -107,49 +114,96 @@ export function renderAgents(props: AgentsProps) {
? (agents.find((agent) => agent.id === selectedId) ?? null) ? (agents.find((agent) => agent.id === selectedId) ?? null)
: null; : null;
const channelEntryCount = props.channels.snapshot
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
: null;
const cronJobCount = selectedId
? props.cron.jobs.filter((j) => j.agentId === selectedId).length
: null;
const tabCounts: Record<string, number | null> = {
files: props.agentFiles.list?.files?.length ?? null,
skills: props.agentSkills.report?.skills?.length ?? null,
channels: channelEntryCount,
cron: cronJobCount || null,
};
return html` return html`
<div class="agents-layout"> <div class="agents-layout">
<section class="card agents-sidebar"> <section class="agents-toolbar">
<div class="row" style="justify-content: space-between;"> <div class="agents-toolbar-row">
<div> <span class="agents-toolbar-label">Agent</span>
<div class="card-title">Agents</div> <div class="agents-control-row">
<div class="card-sub">${agents.length} configured.</div> <div class="agents-control-select">
<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>
</div> </div>
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div> </div>
${ ${
props.error props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>` ? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
: nothing : nothing
} }
<div class="agent-list" style="margin-top: 12px;">
${
agents.length === 0
? html`
<div class="muted">No agents found.</div>
`
: agents.map((agent) => {
const badge = agentBadgeText(agent.id, defaultId);
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
return html`
<button
type="button"
class="agent-row ${selectedId === agent.id ? "active" : ""}"
@click=${() => props.onSelectAgent(agent.id)}
>
<div class="agent-avatar">${emoji || normalizeAgentLabel(agent).slice(0, 1)}</div>
<div class="agent-info">
<div class="agent-title">${normalizeAgentLabel(agent)}</div>
<div class="agent-sub mono">${agent.id}</div>
</div>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
</button>
`;
})
}
</div>
</section> </section>
<section class="agents-main"> <section class="agents-main">
${ ${
@@ -161,29 +215,26 @@ export function renderAgents(props: AgentsProps) {
</div> </div>
` `
: html` : html`
${renderAgentHeader( ${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
selectedAgent,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
)}
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
${ ${
props.activePanel === "overview" props.activePanel === "overview"
? renderAgentOverview({ ? renderAgentOverview({
agent: selectedAgent, agent: selectedAgent,
basePath: props.basePath,
defaultId, defaultId,
configForm: props.configForm, configForm: props.config.form,
agentFilesList: props.agentFilesList, agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null, agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError, agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading, agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.configLoading, configLoading: props.config.loading,
configSaving: props.configSaving, configSaving: props.config.saving,
configDirty: props.configDirty, configDirty: props.config.dirty,
onConfigReload: props.onConfigReload, onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave, onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange, onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange, onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
}) })
: nothing : nothing
} }
@@ -191,13 +242,13 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "files" props.activePanel === "files"
? renderAgentFiles({ ? renderAgentFiles({
agentId: selectedAgent.id, agentId: selectedAgent.id,
agentFilesList: props.agentFilesList, agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFilesLoading, agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFilesError, agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFileActive, agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFileContents, agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFileDrafts, agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFileSaving, agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles, onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile, onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange, onFileDraftChange: props.onFileDraftChange,
@@ -210,13 +261,13 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "tools" props.activePanel === "tools"
? renderAgentTools({ ? renderAgentTools({
agentId: selectedAgent.id, agentId: selectedAgent.id,
configForm: props.configForm, configForm: props.config.form,
configLoading: props.configLoading, configLoading: props.config.loading,
configSaving: props.configSaving, configSaving: props.config.saving,
configDirty: props.configDirty, configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalogLoading, toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalogError, toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalogResult, toolsCatalogResult: props.toolsCatalog.result,
onProfileChange: props.onToolsProfileChange, onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange, onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload, onConfigReload: props.onConfigReload,
@@ -228,15 +279,15 @@ export function renderAgents(props: AgentsProps) {
props.activePanel === "skills" props.activePanel === "skills"
? renderAgentSkills({ ? renderAgentSkills({
agentId: selectedAgent.id, agentId: selectedAgent.id,
report: props.agentSkillsReport, report: props.agentSkills.report,
loading: props.agentSkillsLoading, loading: props.agentSkills.loading,
error: props.agentSkillsError, error: props.agentSkills.error,
activeAgentId: props.agentSkillsAgentId, activeAgentId: props.agentSkills.agentId,
configForm: props.configForm, configForm: props.config.form,
configLoading: props.configLoading, configLoading: props.config.loading,
configSaving: props.configSaving, configSaving: props.config.saving,
configDirty: props.configDirty, configDirty: props.config.dirty,
filter: props.skillsFilter, filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange, onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh, onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle, onToggle: props.onAgentSkillToggle,
@@ -252,16 +303,16 @@ export function renderAgents(props: AgentsProps) {
? renderAgentChannels({ ? renderAgentChannels({
context: buildAgentContext( context: buildAgentContext(
selectedAgent, selectedAgent,
props.configForm, props.config.form,
props.agentFilesList, props.agentFiles.list,
defaultId, defaultId,
props.agentIdentityById[selectedAgent.id] ?? null, props.agentIdentityById[selectedAgent.id] ?? null,
), ),
configForm: props.configForm, configForm: props.config.form,
snapshot: props.channelsSnapshot, snapshot: props.channels.snapshot,
loading: props.channelsLoading, loading: props.channels.loading,
error: props.channelsError, error: props.channels.error,
lastSuccess: props.channelsLastSuccess, lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh, onRefresh: props.onChannelsRefresh,
}) })
: nothing : nothing
@@ -271,17 +322,18 @@ export function renderAgents(props: AgentsProps) {
? renderAgentCron({ ? renderAgentCron({
context: buildAgentContext( context: buildAgentContext(
selectedAgent, selectedAgent,
props.configForm, props.config.form,
props.agentFilesList, props.agentFiles.list,
defaultId, defaultId,
props.agentIdentityById[selectedAgent.id] ?? null, props.agentIdentityById[selectedAgent.id] ?? null,
), ),
agentId: selectedAgent.id, agentId: selectedAgent.id,
jobs: props.cronJobs, jobs: props.cron.jobs,
status: props.cronStatus, status: props.cron.status,
loading: props.cronLoading, loading: props.cron.loading,
error: props.cronError, error: props.cron.error,
onRefresh: props.onCronRefresh, onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
}) })
: nothing : nothing
} }
@@ -292,33 +344,13 @@ export function renderAgents(props: AgentsProps) {
`; `;
} }
function renderAgentHeader( let actionsMenuOpen = false;
agent: AgentsListResult["agents"][number],
defaultId: string | null,
agentIdentity: AgentIdentityResult | null,
) {
const badge = agentBadgeText(agent.id, defaultId);
const displayName = normalizeAgentLabel(agent);
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
const emoji = resolveAgentEmoji(agent, agentIdentity);
return html`
<section class="card agent-header">
<div class="agent-header-main">
<div class="agent-avatar agent-avatar--lg">${emoji || displayName.slice(0, 1)}</div>
<div>
<div class="card-title">${displayName}</div>
<div class="card-sub">${subtitle}</div>
</div>
</div>
<div class="agent-header-meta">
<div class="mono">${agent.id}</div>
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
</div>
</section>
`;
}
function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) { function renderAgentTabs(
active: AgentsPanel,
onSelect: (panel: AgentsPanel) => void,
counts: Record<string, number | null>,
) {
const tabs: Array<{ id: AgentsPanel; label: string }> = [ const tabs: Array<{ id: AgentsPanel; label: string }> = [
{ id: "overview", label: "Overview" }, { id: "overview", label: "Overview" },
{ id: "files", label: "Files" }, { id: "files", label: "Files" },
@@ -336,164 +368,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) =>
type="button" type="button"
@click=${() => onSelect(tab.id)} @click=${() => onSelect(tab.id)}
> >
${tab.label} ${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
</button> </button>
`, `,
)} )}
</div> </div>
`; `;
} }
function renderAgentOverview(params: {
agent: AgentsListResult["agents"][number];
defaultId: string | null;
configForm: Record<string, unknown> | null;
agentFilesList: AgentsFilesListResult | null;
agentIdentity: AgentIdentityResult | null;
agentIdentityLoading: boolean;
agentIdentityError: string | null;
configLoading: boolean;
configSaving: boolean;
configDirty: boolean;
onConfigReload: () => void;
onConfigSave: () => void;
onModelChange: (agentId: string, modelId: string | null) => void;
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
}) {
const {
agent,
configForm,
agentFilesList,
agentIdentity,
agentIdentityLoading,
agentIdentityError,
configLoading,
configSaving,
configDirty,
onConfigReload,
onConfigSave,
onModelChange,
onModelFallbacksChange,
} = params;
const config = resolveAgentConfig(configForm, agent.id);
const workspaceFromFiles =
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
const workspace =
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
const model = config.entry?.model
? resolveModelLabel(config.entry?.model)
: resolveModelLabel(config.defaults?.model);
const defaultModel = resolveModelLabel(config.defaults?.model);
const modelPrimary =
resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null);
const defaultPrimary =
resolveModelPrimary(config.defaults?.model) ||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
const effectivePrimary = modelPrimary ?? defaultPrimary ?? null;
const modelFallbacks = resolveEffectiveModelFallbacks(
config.entry?.model,
config.defaults?.model,
);
const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : "";
const identityName =
agentIdentity?.name?.trim() ||
agent.identity?.name?.trim() ||
agent.name?.trim() ||
config.entry?.name ||
"-";
const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity);
const identityEmoji = resolvedEmoji || "-";
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
const skillCount = skillFilter?.length ?? null;
const identityStatus = agentIdentityLoading
? "Loading…"
: agentIdentityError
? "Unavailable"
: "";
const isDefault = Boolean(params.defaultId && agent.id === params.defaultId);
return html`
<section class="card">
<div class="card-title">Overview</div>
<div class="card-sub">Workspace paths and identity metadata.</div>
<div class="agents-overview-grid" style="margin-top: 16px;">
<div class="agent-kv">
<div class="label">Workspace</div>
<div class="mono">${workspace}</div>
</div>
<div class="agent-kv">
<div class="label">Primary Model</div>
<div class="mono">${model}</div>
</div>
<div class="agent-kv">
<div class="label">Identity Name</div>
<div>${identityName}</div>
${identityStatus ? html`<div class="agent-kv-sub muted">${identityStatus}</div>` : nothing}
</div>
<div class="agent-kv">
<div class="label">Default</div>
<div>${isDefault ? "yes" : "no"}</div>
</div>
<div class="agent-kv">
<div class="label">Identity Emoji</div>
<div>${identityEmoji}</div>
</div>
<div class="agent-kv">
<div class="label">Skills Filter</div>
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
</div>
</div>
<div class="agent-model-select" style="margin-top: 20px;">
<div class="label">Model Selection</div>
<div class="row" style="gap: 12px; flex-wrap: wrap;">
<label class="field" style="min-width: 260px; flex: 1;">
<span>Primary model${isDefault ? " (default)" : ""}</span>
<select
.value=${effectivePrimary ?? ""}
?disabled=${!configForm || configLoading || configSaving}
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? nothing
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
</select>
</label>
<label class="field" style="min-width: 260px; flex: 1;">
<span>Fallbacks (comma-separated)</span>
<input
.value=${fallbackText}
?disabled=${!configForm || configLoading || configSaving}
placeholder="provider/model, provider/model"
@input=${(e: Event) =>
onModelFallbacksChange(
agent.id,
parseFallbackList((e.target as HTMLInputElement).value),
)}
/>
</label>
</div>
<div class="row" style="justify-content: flex-end; gap: 8px;">
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
Reload Config
</button>
<button
class="btn btn--sm primary"
?disabled=${configSaving || !configDirty}
@click=${onConfigSave}
>
${configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</section>
`;
}

View File

@@ -0,0 +1,33 @@
import { html } from "lit";
import { icons } from "../icons.ts";
import type { Tab } from "../navigation.ts";
export type BottomTabsProps = {
activeTab: Tab;
onTabChange: (tab: Tab) => void;
};
const BOTTOM_TABS: Array<{ id: Tab; label: string; icon: keyof typeof icons }> = [
{ id: "overview", label: "Dashboard", icon: "barChart" },
{ id: "chat", label: "Chat", icon: "messageSquare" },
{ id: "sessions", label: "Sessions", icon: "fileText" },
{ id: "config", label: "Settings", icon: "settings" },
];
export function renderBottomTabs(props: BottomTabsProps) {
return html`
<nav class="bottom-tabs">
${BOTTOM_TABS.map(
(tab) => html`
<button
class="bottom-tab ${props.activeTab === tab.id ? "bottom-tab--active" : ""}"
@click=${() => props.onTabChange(tab.id)}
>
<span class="bottom-tab__icon">${icons[tab.icon]}</span>
<span class="bottom-tab__label">${tab.label}</span>
</button>
`,
)}
</nav>
`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,263 @@
import { html, nothing } from "lit";
import { ref } from "lit/directives/ref.js";
import { t } from "../../i18n/index.ts";
import { SLASH_COMMANDS } from "../chat/slash-commands.ts";
import { icons, type IconName } from "../icons.ts";
type PaletteItem = {
id: string;
label: string;
icon: IconName;
category: "search" | "navigation" | "skills";
action: string;
description?: string;
};
const SLASH_PALETTE_ITEMS: PaletteItem[] = SLASH_COMMANDS.map((command) => ({
id: `slash:${command.name}`,
label: `/${command.name}`,
icon: command.icon ?? "terminal",
category: "search",
action: `/${command.name}`,
description: command.description,
}));
const PALETTE_ITEMS: PaletteItem[] = [
...SLASH_PALETTE_ITEMS,
{
id: "nav-overview",
label: "Overview",
icon: "barChart",
category: "navigation",
action: "nav:overview",
},
{
id: "nav-sessions",
label: "Sessions",
icon: "fileText",
category: "navigation",
action: "nav:sessions",
},
{
id: "nav-cron",
label: "Scheduled",
icon: "scrollText",
category: "navigation",
action: "nav:cron",
},
{ id: "nav-skills", label: "Skills", icon: "zap", category: "navigation", action: "nav:skills" },
{
id: "nav-config",
label: "Settings",
icon: "settings",
category: "navigation",
action: "nav:config",
},
{
id: "nav-agents",
label: "Agents",
icon: "folder",
category: "navigation",
action: "nav:agents",
},
{
id: "skill-shell",
label: "Shell Command",
icon: "monitor",
category: "skills",
action: "/skill shell",
description: "Run shell",
},
{
id: "skill-debug",
label: "Debug Mode",
icon: "bug",
category: "skills",
action: "/verbose full",
description: "Toggle debug",
},
];
export function getPaletteItems(): readonly PaletteItem[] {
return PALETTE_ITEMS;
}
export type CommandPaletteProps = {
open: boolean;
query: string;
activeIndex: number;
onToggle: () => void;
onQueryChange: (query: string) => void;
onActiveIndexChange: (index: number) => void;
onNavigate: (tab: string) => void;
onSlashCommand: (command: string) => void;
};
function filteredItems(query: string): PaletteItem[] {
if (!query) {
return PALETTE_ITEMS;
}
const q = query.toLowerCase();
return PALETTE_ITEMS.filter(
(item) =>
item.label.toLowerCase().includes(q) ||
(item.description?.toLowerCase().includes(q) ?? false),
);
}
function groupItems(items: PaletteItem[]): Array<[string, PaletteItem[]]> {
const map = new Map<string, PaletteItem[]>();
for (const item of items) {
const group = map.get(item.category) ?? [];
group.push(item);
map.set(item.category, group);
}
return [...map.entries()];
}
let previouslyFocused: Element | null = null;
function saveFocus() {
previouslyFocused = document.activeElement;
}
function restoreFocus() {
if (previouslyFocused && previouslyFocused instanceof HTMLElement) {
requestAnimationFrame(() => previouslyFocused && (previouslyFocused as HTMLElement).focus());
}
previouslyFocused = null;
}
function selectItem(item: PaletteItem, props: CommandPaletteProps) {
if (item.action.startsWith("nav:")) {
props.onNavigate(item.action.slice(4));
} else {
props.onSlashCommand(item.action);
}
props.onToggle();
restoreFocus();
}
function scrollActiveIntoView() {
requestAnimationFrame(() => {
const el = document.querySelector(".cmd-palette__item--active");
el?.scrollIntoView({ block: "nearest" });
});
}
function handleKeydown(e: KeyboardEvent, props: CommandPaletteProps) {
const items = filteredItems(props.query);
if (items.length === 0 && (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter")) {
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
props.onActiveIndexChange((props.activeIndex + 1) % items.length);
scrollActiveIntoView();
break;
case "ArrowUp":
e.preventDefault();
props.onActiveIndexChange((props.activeIndex - 1 + items.length) % items.length);
scrollActiveIntoView();
break;
case "Enter":
e.preventDefault();
if (items[props.activeIndex]) {
selectItem(items[props.activeIndex], props);
}
break;
case "Escape":
e.preventDefault();
props.onToggle();
restoreFocus();
break;
}
}
const CATEGORY_LABELS: Record<string, string> = {
search: "Search",
navigation: "Navigation",
skills: "Skills",
};
function focusInput(el: Element | undefined) {
if (el) {
saveFocus();
requestAnimationFrame(() => (el as HTMLInputElement).focus());
}
}
export function renderCommandPalette(props: CommandPaletteProps) {
if (!props.open) {
return nothing;
}
const items = filteredItems(props.query);
const grouped = groupItems(items);
return html`
<div class="cmd-palette-overlay" @click=${() => {
props.onToggle();
restoreFocus();
}}>
<div
class="cmd-palette"
@click=${(e: Event) => e.stopPropagation()}
@keydown=${(e: KeyboardEvent) => handleKeydown(e, props)}
>
<input
${ref(focusInput)}
class="cmd-palette__input"
placeholder="${t("overview.palette.placeholder")}"
.value=${props.query}
@input=${(e: Event) => {
props.onQueryChange((e.target as HTMLInputElement).value);
props.onActiveIndexChange(0);
}}
/>
<div class="cmd-palette__results">
${
grouped.length === 0
? html`<div class="cmd-palette__empty">
<span class="nav-item__icon" style="opacity:0.3;width:20px;height:20px">${icons.search}</span>
<span>${t("overview.palette.noResults")}</span>
</div>`
: grouped.map(
([category, groupedItems]) => html`
<div class="cmd-palette__group-label">${CATEGORY_LABELS[category] ?? category}</div>
${groupedItems.map((item) => {
const globalIndex = items.indexOf(item);
const isActive = globalIndex === props.activeIndex;
return html`
<div
class="cmd-palette__item ${isActive ? "cmd-palette__item--active" : ""}"
@click=${(e: Event) => {
e.stopPropagation();
selectItem(item, props);
}}
@mouseenter=${() => props.onActiveIndexChange(globalIndex)}
>
<span class="nav-item__icon">${icons[item.icon]}</span>
<span>${item.label}</span>
${
item.description
? html`<span class="cmd-palette__item-desc muted">${item.description}</span>`
: nothing
}
</div>
`;
})}
`,
)
}
</div>
<div class="cmd-palette__footer">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> select</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
`;
}

View File

@@ -249,11 +249,21 @@ function normalizeUnion(
return res; return res;
} }
const primitiveTypes = new Set(["string", "number", "integer", "boolean"]); const renderableUnionTypes = new Set([
"string",
"number",
"integer",
"boolean",
"object",
"array",
]);
if ( if (
remaining.length > 0 && remaining.length > 0 &&
literals.length === 0 && literals.length === 0 &&
remaining.every((entry) => entry.type && primitiveTypes.has(String(entry.type))) remaining.every((entry) => {
const type = schemaType(entry);
return Boolean(type) && renderableUnionTypes.has(String(type));
})
) { ) {
return { return {
schema: { schema: {

View File

@@ -1,10 +1,13 @@
import { html, nothing, type TemplateResult } from "lit"; import { html, nothing, type TemplateResult } from "lit";
import { icons as sharedIcons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts"; import type { ConfigUiHints } from "../types.ts";
import { import {
defaultValue, defaultValue,
hasSensitiveConfigData,
hintForPath, hintForPath,
humanize, humanize,
pathKey, pathKey,
REDACTED_PLACEHOLDER,
schemaType, schemaType,
type JsonSchema, type JsonSchema,
} from "./config-form.shared.ts"; } from "./config-form.shared.ts";
@@ -100,11 +103,77 @@ type FieldMeta = {
tags: string[]; tags: string[];
}; };
type SensitiveRenderParams = {
path: Array<string | number>;
value: unknown;
hints: ConfigUiHints;
revealSensitive: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
};
type SensitiveRenderState = {
isSensitive: boolean;
isRedacted: boolean;
isRevealed: boolean;
canReveal: boolean;
};
export type ConfigSearchCriteria = { export type ConfigSearchCriteria = {
text: string; text: string;
tags: string[]; tags: string[];
}; };
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
const isRevealed =
isSensitive &&
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
return {
isSensitive,
isRedacted: isSensitive && !isRevealed,
isRevealed,
canReveal: isSensitive,
};
}
function renderSensitiveToggleButton(params: {
path: Array<string | number>;
state: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
}): TemplateResult | typeof nothing {
const { state } = params;
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
return html`
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
title=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-label=${
state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value"
}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
`;
}
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean { function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0)); return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
} }
@@ -331,6 +400,9 @@ export function renderNode(params: {
disabled: boolean; disabled: boolean;
showLabel?: boolean; showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria; searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing { }): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params; const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
@@ -440,6 +512,20 @@ export function renderNode(params: {
}); });
} }
} }
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea({
schema,
value,
path,
hints,
disabled,
showLabel,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
});
} }
// Enum - use segmented for small, dropdown for large // Enum - use segmented for small, dropdown for large
@@ -537,6 +623,9 @@ function renderTextInput(params: {
disabled: boolean; disabled: boolean;
showLabel?: boolean; showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria; searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
inputType: "text" | "number"; inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
@@ -544,17 +633,22 @@ function renderTextInput(params: {
const showLabel = params.showLabel ?? true; const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints); const hint = hintForPath(path, hints);
const { label, help, tags } = resolveFieldMeta(path, schema, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const isSensitive = const sensitiveState = getSensitiveRenderState({
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim()); path,
const placeholder = value,
hint?.placeholder ?? hints,
// oxlint-disable typescript/no-base-to-string revealSensitive: params.revealSensitive ?? false,
(isSensitive isSensitivePathRevealed: params.isSensitivePathRevealed,
? "••••" });
: schema.default !== undefined const placeholder = sensitiveState.isRedacted
? `Default: ${String(schema.default)}` ? REDACTED_PLACEHOLDER
: ""); : (hint?.placeholder ??
const displayValue = value ?? ""; // oxlint-disable typescript/no-base-to-string
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
const displayValue = sensitiveState.isRedacted ? "" : (value ?? "");
const effectiveDisabled = disabled || sensitiveState.isRedacted;
const effectiveInputType =
sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType;
return html` return html`
<div class="cfg-field"> <div class="cfg-field">
@@ -563,12 +657,16 @@ function renderTextInput(params: {
${renderTags(tags)} ${renderTags(tags)}
<div class="cfg-input-wrap"> <div class="cfg-input-wrap">
<input <input
type=${isSensitive ? "password" : inputType} type=${effectiveInputType}
class="cfg-input" class="cfg-input"
placeholder=${placeholder} placeholder=${placeholder}
.value=${displayValue == null ? "" : String(displayValue)} .value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled} ?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@input=${(e: Event) => { @input=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") { if (inputType === "number") {
if (raw.trim() === "") { if (raw.trim() === "") {
@@ -582,13 +680,19 @@ function renderTextInput(params: {
onPatch(path, raw); onPatch(path, raw);
}} }}
@change=${(e: Event) => { @change=${(e: Event) => {
if (inputType === "number") { if (inputType === "number" || sensitiveState.isRedacted) {
return; return;
} }
const raw = (e.target as HTMLInputElement).value; const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim()); onPatch(path, raw.trim());
}} }}
/> />
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${ ${
schema.default !== undefined schema.default !== undefined
? html` ? html`
@@ -596,7 +700,7 @@ function renderTextInput(params: {
type="button" type="button"
class="cfg-input__reset" class="cfg-input__reset"
title="Reset to default" title="Reset to default"
?disabled=${disabled} ?disabled=${effectiveDisabled}
@click=${() => onPatch(path, schema.default)} @click=${() => onPatch(path, schema.default)}
>↺</button> >↺</button>
` `
@@ -702,6 +806,73 @@ function renderSelect(params: {
`; `;
} }
function renderJsonTextarea(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const fallback = jsonValue(value);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const displayValue = sensitiveState.isRedacted ? "" : fallback;
const effectiveDisabled = disabled || sensitiveState.isRedacted;
return html`
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea"
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
rows="3"
.value=${displayValue}
?disabled=${effectiveDisabled}
?readonly=${sensitiveState.isRedacted}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
</div>
</div>
`;
}
function renderObject(params: { function renderObject(params: {
schema: JsonSchema; schema: JsonSchema;
value: unknown; value: unknown;
@@ -711,9 +882,24 @@ function renderObject(params: {
disabled: boolean; disabled: boolean;
showLabel?: boolean; showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria; searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true; const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched = const selfMatched =
@@ -754,6 +940,9 @@ function renderObject(params: {
unsupported, unsupported,
disabled, disabled,
searchCriteria: childSearchCriteria, searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch, onPatch,
}), }),
)} )}
@@ -768,6 +957,9 @@ function renderObject(params: {
disabled, disabled,
reservedKeys: reserved, reservedKeys: reserved,
searchCriteria: childSearchCriteria, searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch, onPatch,
}) })
: nothing : nothing
@@ -818,9 +1010,24 @@ function renderArray(params: {
disabled: boolean; disabled: boolean;
showLabel?: boolean; showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria; searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params; const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params;
const showLabel = params.showLabel ?? true; const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints); const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched = const selfMatched =
@@ -900,6 +1107,9 @@ function renderArray(params: {
disabled, disabled,
searchCriteria: childSearchCriteria, searchCriteria: childSearchCriteria,
showLabel: false, showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch, onPatch,
})} })}
</div> </div>
@@ -922,6 +1132,9 @@ function renderMapField(params: {
disabled: boolean; disabled: boolean;
reservedKeys: Set<string>; reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria; searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult { }): TemplateResult {
const { const {
@@ -934,6 +1147,9 @@ function renderMapField(params: {
reservedKeys, reservedKeys,
onPatch, onPatch,
searchCriteria, searchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = params; } = params;
const anySchema = isAnySchema(schema); const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key)); const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
@@ -985,6 +1201,13 @@ function renderMapField(params: {
${visibleEntries.map(([key, entryValue]) => { ${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key]; const valuePath = [...path, key];
const fallback = jsonValue(entryValue); const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html` return html`
<div class="cfg-map__item"> <div class="cfg-map__item">
<div class="cfg-map__item-header"> <div class="cfg-map__item-header">
@@ -1028,26 +1251,40 @@ function renderMapField(params: {
${ ${
anySchema anySchema
? html` ? html`
<textarea <div class="cfg-input-wrap">
class="cfg-textarea cfg-textarea--sm" <textarea
placeholder="JSON value" class="cfg-textarea cfg-textarea--sm"
rows="2" placeholder=${
.value=${fallback} sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
?disabled=${disabled}
@change=${(e: Event) => {
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
} }
try { rows="2"
onPatch(valuePath, JSON.parse(raw)); .value=${sensitiveState.isRedacted ? "" : fallback}
} catch { ?disabled=${disabled || sensitiveState.isRedacted}
target.value = fallback; ?readonly=${sensitiveState.isRedacted}
} @change=${(e: Event) => {
}} if (sensitiveState.isRedacted) {
></textarea> return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path: valuePath,
state: sensitiveState,
disabled,
onToggleSensitivePath,
})}
</div>
` `
: renderNode({ : renderNode({
schema, schema,
@@ -1058,6 +1295,9 @@ function renderMapField(params: {
disabled, disabled,
searchCriteria, searchCriteria,
showLabel: false, showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch, onPatch,
}) })
} }

View File

@@ -13,6 +13,9 @@ export type ConfigFormProps = {
searchQuery?: string; searchQuery?: string;
activeSection?: string | null; activeSection?: string | null;
activeSubsection?: string | null; activeSubsection?: string | null;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
}; };
@@ -431,6 +434,9 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
showLabel: false, showLabel: false,
searchCriteria, searchCriteria,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>
@@ -466,6 +472,9 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false, disabled: props.disabled ?? false,
showLabel: false, showLabel: false,
searchCriteria, searchCriteria,
revealSensitive: props.revealSensitive ?? false,
isSensitivePathRevealed: props.isSensitivePathRevealed,
onToggleSensitivePath: props.onToggleSensitivePath,
onPatch: props.onPatch, onPatch: props.onPatch,
})} })}
</div> </div>

View File

@@ -1,4 +1,4 @@
import type { ConfigUiHints } from "../types.ts"; import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
export type JsonSchema = { export type JsonSchema = {
type?: string | string[]; type?: string | string[];
@@ -94,3 +94,110 @@ export function humanize(raw: string) {
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase()); .replace(/^./, (m) => m.toUpperCase());
} }
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
"maxtokens",
"maxoutputtokens",
"maxinputtokens",
"maxcompletiontokens",
"contexttokens",
"totaltokens",
"tokencount",
"tokenlimit",
"tokenbudget",
"passwordfile",
] as const;
const SENSITIVE_PATTERNS = [
/token$/i,
/password/i,
/secret/i,
/api.?key/i,
/serviceaccount(?:ref)?$/i,
];
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
function isEnvVarPlaceholder(value: string): boolean {
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
}
export function isSensitiveConfigPath(path: string): boolean {
const lowerPath = path.toLowerCase();
const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
function isSensitiveLeafValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0 && !isEnvVarPlaceholder(value);
}
return value !== undefined && value !== null;
}
function isHintSensitive(hint: ConfigUiHint | undefined): boolean {
return hint?.sensitive ?? false;
}
export function hasSensitiveConfigData(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): boolean {
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return true;
}
if (Array.isArray(value)) {
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
hasSensitiveConfigData(childValue, [...path, childKey], hints),
);
}
return false;
}
export function countSensitiveConfigValues(
value: unknown,
path: Array<string | number>,
hints: ConfigUiHints,
): number {
if (value == null) {
return 0;
}
const key = pathKey(path);
const hint = hintForPath(path, hints);
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
if (pathIsSensitive && isSensitiveLeafValue(value)) {
return 1;
}
if (Array.isArray(value)) {
return value.reduce(
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
0,
);
}
if (value && typeof value === "object") {
return Object.entries(value as Record<string, unknown>).reduce(
(count, [childKey, childValue]) =>
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
0,
);
}
return 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -360,7 +360,9 @@ export function renderCron(props: CronProps) {
props.runsScope === "all" props.runsScope === "all"
? t("cron.jobList.allJobs") ? t("cron.jobList.allJobs")
: (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob")); : (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob"));
const runs = props.runs; const runs = props.runs.toSorted((a, b) =>
props.runsSortDir === "asc" ? a.ts - b.ts : b.ts - a.ts,
);
const runStatusOptions = getRunStatusOptions(); const runStatusOptions = getRunStatusOptions();
const runDeliveryOptions = getRunDeliveryOptions(); const runDeliveryOptions = getRunDeliveryOptions();
const selectedStatusLabels = runStatusOptions const selectedStatusLabels = runStatusOptions
@@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) {
?disabled=${props.busy} ?disabled=${props.busy}
@click=${(event: Event) => { @click=${(event: Event) => {
event.stopPropagation(); event.stopPropagation();
selectAnd(() => props.onLoadRuns(job.id)); props.onLoadRuns(job.id);
}} }}
> >
${t("cron.jobList.history")} ${t("cron.jobList.history")}

View File

@@ -34,7 +34,7 @@ export function renderDebug(props: DebugProps) {
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues"; critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
return html` return html`
<section class="grid grid-cols-2"> <section class="grid">
<div class="card"> <div class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>

View File

@@ -1,5 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts"; import { icons } from "../icons.ts";
import { formatPresenceAge } from "../presenter.ts";
import type { PresenceEntry } from "../types.ts"; import type { PresenceEntry } from "../types.ts";
export type InstancesProps = { export type InstancesProps = {
@@ -10,7 +11,11 @@ export type InstancesProps = {
onRefresh: () => void; onRefresh: () => void;
}; };
let hostsRevealed = false;
export function renderInstances(props: InstancesProps) { export function renderInstances(props: InstancesProps) {
const masked = !hostsRevealed;
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
@@ -18,9 +23,24 @@ export function renderInstances(props: InstancesProps) {
<div class="card-title">Connected Instances</div> <div class="card-title">Connected Instances</div>
<div class="card-sub">Presence beacons from the gateway and clients.</div> <div class="card-sub">Presence beacons from the gateway and clients.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <div class="row" style="gap: 8px;">
${props.loading ? "Loading…" : "Refresh"} <button
</button> class="btn btn--icon ${masked ? "" : "active"}"
@click=${() => {
hostsRevealed = !hostsRevealed;
props.onRefresh();
}}
title=${masked ? "Show hosts and IPs" : "Hide hosts and IPs"}
aria-label="Toggle host visibility"
aria-pressed=${!masked}
style="width: 36px; height: 36px;"
>
${masked ? icons.eyeOff : icons.eye}
</button>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div> </div>
${ ${
props.lastError props.lastError
@@ -42,16 +62,18 @@ export function renderInstances(props: InstancesProps) {
? html` ? html`
<div class="muted">No instances reported yet.</div> <div class="muted">No instances reported yet.</div>
` `
: props.entries.map((entry) => renderEntry(entry)) : props.entries.map((entry) => renderEntry(entry, masked))
} }
</div> </div>
</section> </section>
`; `;
} }
function renderEntry(entry: PresenceEntry) { function renderEntry(entry: PresenceEntry, masked: boolean) {
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a"; const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
const mode = entry.mode ?? "unknown"; const mode = entry.mode ?? "unknown";
const host = entry.host ?? "unknown host";
const ip = entry.ip ?? null;
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
const scopesLabel = const scopesLabel =
@@ -63,8 +85,12 @@ function renderEntry(entry: PresenceEntry) {
return html` return html`
<div class="list-item"> <div class="list-item">
<div class="list-main"> <div class="list-main">
<div class="list-title">${entry.host ?? "unknown host"}</div> <div class="list-title">
<div class="list-sub">${formatPresenceSummary(entry)}</div> <span class="${masked ? "redacted" : ""}">${host}</span>
</div>
<div class="list-sub">
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
</div>
<div class="chip-row"> <div class="chip-row">
<span class="chip">${mode}</span> <span class="chip">${mode}</span>
${roles.map((role) => html`<span class="chip">${role}</span>`)} ${roles.map((role) => html`<span class="chip">${role}</span>`)}

View File

@@ -0,0 +1,132 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import { renderThemeToggle } from "../app-render.helpers.ts";
import type { AppViewState } from "../app-view-state.ts";
import { icons } from "../icons.ts";
import { normalizeBasePath } from "../navigation.ts";
export function renderLoginGate(state: AppViewState) {
const basePath = normalizeBasePath(state.basePath ?? "");
const faviconSrc = basePath ? `${basePath}/favicon.svg` : "/favicon.svg";
return html`
<div class="login-gate">
<div class="login-gate__theme">${renderThemeToggle(state)}</div>
<div class="login-gate__card">
<div class="login-gate__header">
<img class="login-gate__logo" src=${faviconSrc} alt="OpenClaw" />
<div class="login-gate__title">OpenClaw</div>
<div class="login-gate__sub">${t("login.subtitle")}</div>
</div>
<div class="login-gate__form">
<label class="field">
<span>${t("overview.access.wsUrl")}</span>
<input
.value=${state.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.applySettings({ ...state.settings, gatewayUrl: v });
}}
placeholder="ws://127.0.0.1:18789"
/>
</label>
<label class="field">
<span>${t("overview.access.token")}</span>
<div class="login-gate__secret-row">
<input
type=${state.loginShowGatewayToken ? "text" : "password"}
autocomplete="off"
spellcheck="false"
.value=${state.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.applySettings({ ...state.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN (${t("login.passwordPlaceholder")})"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
state.connect();
}
}}
/>
<button
type="button"
class="btn btn--icon ${state.loginShowGatewayToken ? "active" : ""}"
title=${state.loginShowGatewayToken ? "Hide token" : "Show token"}
aria-label="Toggle token visibility"
aria-pressed=${state.loginShowGatewayToken}
@click=${() => {
state.loginShowGatewayToken = !state.loginShowGatewayToken;
}}
>
${state.loginShowGatewayToken ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<label class="field">
<span>${t("overview.access.password")}</span>
<div class="login-gate__secret-row">
<input
type=${state.loginShowGatewayPassword ? "text" : "password"}
autocomplete="off"
spellcheck="false"
.value=${state.password}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
state.password = v;
}}
placeholder="${t("login.passwordPlaceholder")}"
@keydown=${(e: KeyboardEvent) => {
if (e.key === "Enter") {
state.connect();
}
}}
/>
<button
type="button"
class="btn btn--icon ${state.loginShowGatewayPassword ? "active" : ""}"
title=${state.loginShowGatewayPassword ? "Hide password" : "Show password"}
aria-label="Toggle password visibility"
aria-pressed=${state.loginShowGatewayPassword}
@click=${() => {
state.loginShowGatewayPassword = !state.loginShowGatewayPassword;
}}
>
${state.loginShowGatewayPassword ? icons.eye : icons.eyeOff}
</button>
</div>
</label>
<button
class="btn primary login-gate__connect"
@click=${() => state.connect()}
>
${t("common.connect")}
</button>
</div>
${
state.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${state.lastError}</div>
</div>`
: ""
}
<div class="login-gate__help">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
</ol>
<div class="login-gate__docs">
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,61 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
import { icons, type IconName } from "../icons.ts";
import type { AttentionItem } from "../types.ts";
export type OverviewAttentionProps = {
items: AttentionItem[];
};
function severityClass(severity: string) {
if (severity === "error") {
return "danger";
}
if (severity === "warning") {
return "warn";
}
return "";
}
function attentionIcon(name: string) {
if (name in icons) {
return icons[name as IconName];
}
return icons.radio;
}
export function renderOverviewAttention(props: OverviewAttentionProps) {
if (props.items.length === 0) {
return nothing;
}
return html`
<section class="card ov-attention">
<div class="card-title">${t("overview.attention.title")}</div>
<div class="ov-attention-list">
${props.items.map(
(item) => html`
<div class="ov-attention-item ${severityClass(item.severity)}">
<span class="ov-attention-icon">${attentionIcon(item.icon)}</span>
<div class="ov-attention-body">
<div class="ov-attention-title">${item.title}</div>
<div class="muted">${item.description}</div>
</div>
${
item.href
? html`<a
class="ov-attention-link"
href=${item.href}
target=${item.external ? EXTERNAL_LINK_TARGET : nothing}
rel=${item.external ? buildExternalLinkRel() : nothing}
>${t("common.docs")}</a>`
: nothing
}
</div>
`,
)}
</div>
</section>
`;
}

View File

@@ -0,0 +1,162 @@
import { html, nothing, type TemplateResult } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { t } from "../../i18n/index.ts";
import { formatCost, formatTokens, formatRelativeTimestamp } from "../format.ts";
import { formatNextRun } from "../presenter.ts";
import type {
SessionsUsageResult,
SessionsListResult,
SkillStatusReport,
CronJob,
CronStatus,
} from "../types.ts";
export type OverviewCardsProps = {
usageResult: SessionsUsageResult | null;
sessionsResult: SessionsListResult | null;
skillsReport: SkillStatusReport | null;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
presenceCount: number;
onNavigate: (tab: string) => void;
};
const DIGIT_RUN = /\d{3,}/g;
function blurDigits(value: string): TemplateResult {
const escaped = value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const blurred = escaped.replace(DIGIT_RUN, (m) => `<span class="blur-digits">${m}</span>`);
return html`${unsafeHTML(blurred)}`;
}
type StatCard = {
kind: string;
tab: string;
label: string;
value: string | TemplateResult;
hint: string | TemplateResult;
};
function renderStatCard(card: StatCard, onNavigate: (tab: string) => void) {
return html`
<button class="ov-card" data-kind=${card.kind} @click=${() => onNavigate(card.tab)}>
<span class="ov-card__label">${card.label}</span>
<span class="ov-card__value">${card.value}</span>
<span class="ov-card__hint">${card.hint}</span>
</button>
`;
}
function renderSkeletonCards() {
return html`
<section class="ov-cards">
${[0, 1, 2, 3].map(
(i) => html`
<div class="ov-card" style="cursor:default;animation-delay:${i * 50}ms">
<span class="skeleton skeleton-line" style="width:60px;height:10px"></span>
<span class="skeleton skeleton-stat"></span>
<span class="skeleton skeleton-line skeleton-line--medium" style="height:12px"></span>
</div>
`,
)}
</section>
`;
}
export function renderOverviewCards(props: OverviewCardsProps) {
const dataLoaded =
props.usageResult != null || props.sessionsResult != null || props.skillsReport != null;
if (!dataLoaded) {
return renderSkeletonCards();
}
const totals = props.usageResult?.totals;
const totalCost = formatCost(totals?.totalCost);
const totalTokens = formatTokens(totals?.totalTokens);
const totalMessages = totals ? String(props.usageResult?.aggregates?.messages?.total ?? 0) : "0";
const sessionCount = props.sessionsResult?.count ?? null;
const skills = props.skillsReport?.skills ?? [];
const enabledSkills = skills.filter((s) => !s.disabled).length;
const blockedSkills = skills.filter((s) => s.blockedByAllowlist).length;
const totalSkills = skills.length;
const cronEnabled = props.cronStatus?.enabled ?? null;
const cronNext = props.cronStatus?.nextWakeAtMs ?? null;
const cronJobCount = props.cronJobs.length;
const failedCronCount = props.cronJobs.filter((j) => j.state?.lastStatus === "error").length;
const cronValue =
cronEnabled == null
? t("common.na")
: cronEnabled
? `${cronJobCount} jobs`
: t("common.disabled");
const cronHint =
failedCronCount > 0
? html`<span class="danger">${failedCronCount} failed</span>`
: cronNext
? t("overview.stats.cronNext", { time: formatNextRun(cronNext) })
: "";
const cards: StatCard[] = [
{
kind: "cost",
tab: "usage",
label: t("overview.cards.cost"),
value: totalCost,
hint: `${totalTokens} tokens · ${totalMessages} msgs`,
},
{
kind: "sessions",
tab: "sessions",
label: t("overview.stats.sessions"),
value: String(sessionCount ?? t("common.na")),
hint: t("overview.stats.sessionsHint"),
},
{
kind: "skills",
tab: "skills",
label: t("overview.cards.skills"),
value: `${enabledSkills}/${totalSkills}`,
hint: blockedSkills > 0 ? `${blockedSkills} blocked` : `${enabledSkills} active`,
},
{
kind: "cron",
tab: "cron",
label: t("overview.stats.cron"),
value: cronValue,
hint: cronHint,
},
];
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
return html`
<section class="ov-cards">
${cards.map((c) => renderStatCard(c, props.onNavigate))}
</section>
${
sessions.length > 0
? html`
<section class="ov-recent">
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
<ul class="ov-recent__list">
${sessions.map(
(s) => html`
<li class="ov-recent__row">
<span class="ov-recent__key">${blurDigits(s.displayName || s.label || s.key)}</span>
<span class="ov-recent__model">${s.model ?? ""}</span>
<span class="ov-recent__time">${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span>
</li>
`,
)}
</ul>
</section>
`
: nothing
}
`;
}

View File

@@ -0,0 +1,42 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { icons } from "../icons.ts";
import { formatEventPayload } from "../presenter.ts";
export type OverviewEventLogProps = {
events: EventLogEntry[];
};
export function renderOverviewEventLog(props: OverviewEventLogProps) {
if (props.events.length === 0) {
return nothing;
}
const visible = props.events.slice(0, 20);
return html`
<details class="card ov-event-log">
<summary class="ov-expandable-toggle">
<span class="nav-item__icon">${icons.radio}</span>
${t("overview.eventLog.title")}
<span class="ov-count-badge">${props.events.length}</span>
</summary>
<div class="ov-event-log-list">
${visible.map(
(entry) => html`
<div class="ov-event-log-entry">
<span class="ov-event-log-ts">${new Date(entry.ts).toLocaleTimeString()}</span>
<span class="ov-event-log-name">${entry.event}</span>
${
entry.payload
? html`<span class="ov-event-log-payload muted">${formatEventPayload(entry.payload).slice(0, 120)}</span>`
: nothing
}
</div>
`,
)}
</div>
</details>
`;
}

View File

@@ -1,5 +1,31 @@
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
const AUTH_REQUIRED_CODES = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const AUTH_FAILURE_CODES = new Set<string>([
...AUTH_REQUIRED_CODES,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const INSECURE_CONTEXT_CODES = new Set<string>([
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
]);
/** Whether the overview should show device-pairing guidance for this error. */ /** Whether the overview should show device-pairing guidance for this error. */
export function shouldShowPairingHint( export function shouldShowPairingHint(
connected: boolean, connected: boolean,
@@ -14,3 +40,44 @@ export function shouldShowPairingHint(
} }
return lastError.toLowerCase().includes("pairing required"); return lastError.toLowerCase().includes("pairing required");
} }
export function shouldShowAuthHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return AUTH_FAILURE_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("unauthorized") || lower.includes("connect failed");
}
export function shouldShowAuthRequiredHint(
hasToken: boolean,
hasPassword: boolean,
lastErrorCode?: string | null,
): boolean {
if (lastErrorCode) {
return AUTH_REQUIRED_CODES.has(lastErrorCode);
}
return !hasToken && !hasPassword;
}
export function shouldShowInsecureContextHint(
connected: boolean,
lastError: string | null,
lastErrorCode?: string | null,
): boolean {
if (connected || !lastError) {
return false;
}
if (lastErrorCode) {
return INSECURE_CONTEXT_CODES.has(lastErrorCode);
}
const lower = lastError.toLowerCase();
return lower.includes("secure context") || lower.includes("device identity required");
}

View File

@@ -0,0 +1,44 @@
import { html, nothing } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
/** Strip ANSI escape codes (SGR, OSC-8) for readable log display. */
function stripAnsi(text: string): string {
/* eslint-disable no-control-regex -- stripping ANSI escape sequences requires matching ESC */
return text.replace(/\x1b\]8;;.*?\x1b\\|\x1b\]8;;\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
}
export type OverviewLogTailProps = {
lines: string[];
onRefreshLogs: () => void;
};
export function renderOverviewLogTail(props: OverviewLogTailProps) {
if (props.lines.length === 0) {
return nothing;
}
const displayLines = props.lines
.slice(-50)
.map((line) => stripAnsi(line))
.join("\n");
return html`
<details class="card ov-log-tail">
<summary class="ov-expandable-toggle">
<span class="nav-item__icon">${icons.scrollText}</span>
${t("overview.logTail.title")}
<span class="ov-count-badge">${props.lines.length}</span>
<span
class="ov-log-refresh"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
props.onRefreshLogs();
}}
>${icons.loader}</span>
</summary>
<pre class="ov-log-tail-content">${displayLines}</pre>
</details>
`;
}

View File

@@ -0,0 +1,31 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
export type OverviewQuickActionsProps = {
onNavigate: (tab: string) => void;
onRefresh: () => void;
};
export function renderOverviewQuickActions(props: OverviewQuickActionsProps) {
return html`
<section class="ov-quick-actions">
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("chat")}>
<span class="nav-item__icon">${icons.messageSquare}</span>
${t("overview.quickActions.newSession")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("cron")}>
<span class="nav-item__icon">${icons.zap}</span>
${t("overview.quickActions.automation")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onRefresh()}>
<span class="nav-item__icon">${icons.loader}</span>
${t("overview.quickActions.refreshAll")}
</button>
<button class="btn ov-quick-action-btn" @click=${() => props.onNavigate("sessions")}>
<span class="nav-item__icon">${icons.monitor}</span>
${t("overview.quickActions.terminal")}
</button>
</section>
`;
}

View File

@@ -1,12 +1,29 @@
import { html } from "lit"; import { html, nothing } from "lit";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts"; import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
import type { EventLogEntry } from "../app-events.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts"; import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts"; import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
import type { GatewayHelloOk } from "../gateway.ts"; import type { GatewayHelloOk } from "../gateway.ts";
import { formatNextRun } from "../presenter.ts"; import { icons } from "../icons.ts";
import type { UiSettings } from "../storage.ts"; import type { UiSettings } from "../storage.ts";
import { shouldShowPairingHint } from "./overview-hints.ts"; import type {
AttentionItem,
CronJob,
CronStatus,
SessionsListResult,
SessionsUsageResult,
SkillStatusReport,
} from "../types.ts";
import { renderOverviewAttention } from "./overview-attention.ts";
import { renderOverviewCards } from "./overview-cards.ts";
import { renderOverviewEventLog } from "./overview-event-log.ts";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
import { renderOverviewLogTail } from "./overview-log-tail.ts";
export type OverviewProps = { export type OverviewProps = {
connected: boolean; connected: boolean;
@@ -20,24 +37,39 @@ export type OverviewProps = {
cronEnabled: boolean | null; cronEnabled: boolean | null;
cronNext: number | null; cronNext: number | null;
lastChannelsRefresh: number | null; lastChannelsRefresh: number | null;
// New dashboard data
usageResult: SessionsUsageResult | null;
sessionsResult: SessionsListResult | null;
skillsReport: SkillStatusReport | null;
cronJobs: CronJob[];
cronStatus: CronStatus | null;
attentionItems: AttentionItem[];
eventLog: EventLogEntry[];
overviewLogLines: string[];
showGatewayToken: boolean;
showGatewayPassword: boolean;
onSettingsChange: (next: UiSettings) => void; onSettingsChange: (next: UiSettings) => void;
onPasswordChange: (next: string) => void; onPasswordChange: (next: string) => void;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
onToggleGatewayTokenVisibility: () => void;
onToggleGatewayPasswordVisibility: () => void;
onConnect: () => void; onConnect: () => void;
onRefresh: () => void; onRefresh: () => void;
onNavigate: (tab: string) => void;
onRefreshLogs: () => void;
}; };
export function renderOverview(props: OverviewProps) { export function renderOverview(props: OverviewProps) {
const snapshot = props.hello?.snapshot as const snapshot = props.hello?.snapshot as
| { | {
uptimeMs?: number; uptimeMs?: number;
policy?: { tickIntervalMs?: number };
authMode?: "none" | "token" | "password" | "trusted-proxy"; authMode?: "none" | "token" | "password" | "trusted-proxy";
} }
| undefined; | undefined;
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na"); const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
const tick = snapshot?.policy?.tickIntervalMs const tickIntervalMs = props.hello?.policy?.tickIntervalMs;
? `${snapshot.policy.tickIntervalMs}ms` const tick = tickIntervalMs
? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
: t("common.na"); : t("common.na");
const authMode = snapshot?.authMode; const authMode = snapshot?.authMode;
const isTrustedProxy = authMode === "trusted-proxy"; const isTrustedProxy = authMode === "trusted-proxy";
@@ -74,38 +106,12 @@ export function renderOverview(props: OverviewProps) {
if (props.connected || !props.lastError) { if (props.connected || !props.lastError) {
return null; return null;
} }
const lower = props.lastError.toLowerCase(); if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
const authRequiredCodes = new Set<string>([
ConnectErrorDetailCodes.AUTH_REQUIRED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
]);
const authFailureCodes = new Set<string>([
...authRequiredCodes,
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
]);
const authFailed = props.lastErrorCode
? authFailureCodes.has(props.lastErrorCode)
: lower.includes("unauthorized") || lower.includes("connect failed");
if (!authFailed) {
return null; return null;
} }
const hasToken = Boolean(props.settings.token.trim()); const hasToken = Boolean(props.settings.token.trim());
const hasPassword = Boolean(props.password.trim()); const hasPassword = Boolean(props.password.trim());
const isAuthRequired = props.lastErrorCode if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
? authRequiredCodes.has(props.lastErrorCode)
: !hasToken && !hasPassword;
if (isAuthRequired) {
return html` return html`
<div class="muted" style="margin-top: 8px"> <div class="muted" style="margin-top: 8px">
${t("overview.auth.required")} ${t("overview.auth.required")}
@@ -151,15 +157,7 @@ export function renderOverview(props: OverviewProps) {
if (isSecureContext) { if (isSecureContext) {
return null; return null;
} }
const lower = props.lastError.toLowerCase(); if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
const insecureContextCode =
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
if (
!insecureContextCode &&
!lower.includes("secure context") &&
!lower.includes("device identity required")
) {
return null; return null;
} }
return html` return html`
@@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) {
const currentLocale = i18n.getLocale(); const currentLocale = i18n.getLocale();
return html` return html`
<section class="grid grid-cols-2"> <section class="grid">
<div class="card"> <div class="card">
<div class="card-title">${t("overview.access.title")}</div> <div class="card-title">${t("overview.access.title")}</div>
<div class="card-sub">${t("overview.access.subtitle")}</div> <div class="card-sub">${t("overview.access.subtitle")}</div>
<div class="form-grid" style="margin-top: 16px;"> <div class="ov-access-grid" style="margin-top: 16px;">
<label class="field"> <label class="field ov-access-grid__full">
<span>${t("overview.access.wsUrl")}</span> <span>${t("overview.access.wsUrl")}</span>
<input <input
.value=${props.settings.gatewayUrl} .value=${props.settings.gatewayUrl}
@@ -220,26 +218,57 @@ export function renderOverview(props: OverviewProps) {
: html` : html`
<label class="field"> <label class="field">
<span>${t("overview.access.token")}</span> <span>${t("overview.access.token")}</span>
<input <div style="display: flex; align-items: center; gap: 8px;">
.value=${props.settings.token} <input
@input=${(e: Event) => { type=${props.showGatewayToken ? "text" : "password"}
const v = (e.target as HTMLInputElement).value; autocomplete="off"
props.onSettingsChange({ ...props.settings, token: v }); style="flex: 1;"
}} .value=${props.settings.token}
placeholder="OPENCLAW_GATEWAY_TOKEN" @input=${(e: Event) => {
/> const v = (e.target as HTMLInputElement).value;
props.onSettingsChange({ ...props.settings, token: v });
}}
placeholder="OPENCLAW_GATEWAY_TOKEN"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayToken ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayToken ? "Hide token" : "Show token"}
aria-label="Toggle token visibility"
aria-pressed=${props.showGatewayToken}
@click=${props.onToggleGatewayTokenVisibility}
>
${props.showGatewayToken ? icons.eye : icons.eyeOff}
</button>
</div>
</label> </label>
<label class="field"> <label class="field">
<span>${t("overview.access.password")}</span> <span>${t("overview.access.password")}</span>
<input <div style="display: flex; align-items: center; gap: 8px;">
type="password" <input
.value=${props.password} type=${props.showGatewayPassword ? "text" : "password"}
@input=${(e: Event) => { autocomplete="off"
const v = (e.target as HTMLInputElement).value; style="flex: 1;"
props.onPasswordChange(v); .value=${props.password}
}} @input=${(e: Event) => {
placeholder="system or shared password" const v = (e.target as HTMLInputElement).value;
/> props.onPasswordChange(v);
}}
placeholder="system or shared password"
/>
<button
type="button"
class="btn btn--icon ${props.showGatewayPassword ? "active" : ""}"
style="width: 36px; height: 36px;"
title=${props.showGatewayPassword ? "Hide password" : "Show password"}
aria-label="Toggle password visibility"
aria-pressed=${props.showGatewayPassword}
@click=${props.onToggleGatewayPasswordVisibility}
>
${props.showGatewayPassword ? icons.eye : icons.eyeOff}
</button>
</div>
</label> </label>
` `
} }
@@ -277,6 +306,30 @@ export function renderOverview(props: OverviewProps) {
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint") isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span> }</span>
</div> </div>
${
!props.connected
? html`
<div class="login-gate__help" style="margin-top: 16px;">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
</ol>
<div class="login-gate__docs">
${t("overview.connection.docsHint")}
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
</div>
`
: nothing
}
</div> </div>
<div class="card"> <div class="card">
@@ -321,45 +374,32 @@ export function renderOverview(props: OverviewProps) {
</div> </div>
</section> </section>
<section class="grid grid-cols-3" style="margin-top: 18px;"> <div class="ov-section-divider"></div>
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.instances")}</div> ${renderOverviewCards({
<div class="stat-value">${props.presenceCount}</div> usageResult: props.usageResult,
<div class="muted">${t("overview.stats.instancesHint")}</div> sessionsResult: props.sessionsResult,
</div> skillsReport: props.skillsReport,
<div class="card stat-card"> cronJobs: props.cronJobs,
<div class="stat-label">${t("overview.stats.sessions")}</div> cronStatus: props.cronStatus,
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div> presenceCount: props.presenceCount,
<div class="muted">${t("overview.stats.sessionsHint")}</div> onNavigate: props.onNavigate,
</div> })}
<div class="card stat-card">
<div class="stat-label">${t("overview.stats.cron")}</div> ${renderOverviewAttention({ items: props.attentionItems })}
<div class="stat-value">
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")} <div class="ov-section-divider"></div>
</div>
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div> <div class="ov-bottom-grid" style="margin-top: 18px;">
</div> ${renderOverviewEventLog({
</section> events: props.eventLog,
})}
${renderOverviewLogTail({
lines: props.overviewLogLines,
onRefreshLogs: props.onRefreshLogs,
})}
</div>
<section class="card" style="margin-top: 18px;">
<div class="card-title">${t("overview.notes.title")}</div>
<div class="card-sub">${t("overview.notes.subtitle")}</div>
<div class="note-grid" style="margin-top: 14px;">
<div>
<div class="note-title">${t("overview.notes.tailscaleTitle")}</div>
<div class="muted">
${t("overview.notes.tailscaleText")}
</div>
</div>
<div>
<div class="note-title">${t("overview.notes.sessionTitle")}</div>
<div class="muted">${t("overview.notes.sessionText")}</div>
</div>
<div>
<div class="note-title">${t("overview.notes.cronTitle")}</div>
<div class="muted">${t("overview.notes.cronText")}</div>
</div>
</div>
</section>
`; `;
} }

View File

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

View File

@@ -10,6 +10,7 @@ import {
} from "./skills-shared.ts"; } from "./skills-shared.ts";
export type SkillsProps = { export type SkillsProps = {
connected: boolean;
loading: boolean; loading: boolean;
report: SkillStatusReport | null; report: SkillStatusReport | null;
error: string | null; error: string | null;
@@ -40,16 +41,22 @@ export function renderSkills(props: SkillsProps) {
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<div> <div>
<div class="card-title">Skills</div> <div class="card-title">Skills</div>
<div class="card-sub">Bundled, managed, and workspace skills.</div> <div class="card-sub">Installed skills and their status.</div>
</div> </div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}> <button class="btn" ?disabled=${props.loading || !props.connected} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"} ${props.loading ? "Loading…" : "Refresh"}
</button> </button>
</div> </div>
<div class="filters" style="margin-top: 14px;"> <div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
<label class="field" style="flex: 1;"> <a
<span>Filter</span> class="btn"
href="https://clawhub.com"
target="_blank"
rel="noreferrer"
title="Browse skills on ClawHub"
>Browse Skills Store</a>
<label class="field" style="flex: 1; min-width: 180px;">
<input <input
.value=${props.filter} .value=${props.filter}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)} @input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
@@ -68,7 +75,13 @@ export function renderSkills(props: SkillsProps) {
${ ${
filtered.length === 0 filtered.length === 0
? html` ? html`
<div class="muted" style="margin-top: 16px">No skills found.</div> <div class="muted" style="margin-top: 16px">
${
!props.connected && !props.report
? "Not connected to gateway."
: "No skills found."
}
</div>
` `
: html` : html`
<div class="agent-skills-groups" style="margin-top: 16px;"> <div class="agent-skills-groups" style="margin-top: 16px;">

View File

@@ -39,5 +39,23 @@ export default defineConfig(() => {
port: 5173, port: 5173,
strictPort: true, strictPort: true,
}, },
plugins: [
{
name: "control-ui-dev-stubs",
configureServer(server) {
server.middlewares.use("/__openclaw/control-ui-config.json", (_req, res) => {
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
basePath: "/",
assistantName: "",
assistantAvatar: "",
assistantAgentId: "",
}),
);
});
},
},
],
}; };
}); });