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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -123,3 +123,11 @@ dist/protocol.schema.json
|
||||
# Synthing
|
||||
**/.stfolder/
|
||||
.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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
@@ -36,7 +37,6 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
|
||||
"sonnet-4.6": "claude-sonnet-4-6",
|
||||
"sonnet-4.5": "claude-sonnet-4-5",
|
||||
};
|
||||
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
|
||||
function normalizeAliasKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
@@ -629,8 +629,8 @@ export function resolveThinkingDefault(params: {
|
||||
model: string;
|
||||
catalog?: ModelCatalogEntry[];
|
||||
}): ThinkLevel {
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
const modelLower = params.model.toLowerCase();
|
||||
const _normalizedProvider = normalizeProviderId(params.provider);
|
||||
const _modelLower = params.model.toLowerCase();
|
||||
const configuredModels = params.cfg.agents?.defaults?.models;
|
||||
const canonicalKey = modelKey(params.provider, params.model);
|
||||
const legacyKey = legacyModelKey(params.provider, params.model);
|
||||
@@ -652,21 +652,11 @@ export function resolveThinkingDefault(params: {
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
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";
|
||||
return resolveThinkingDefaultForModel({
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
catalog: params.catalog,
|
||||
});
|
||||
}
|
||||
|
||||
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
listThinkingLevels,
|
||||
normalizeReasoningLevel,
|
||||
normalizeThinkLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "./thinking.js";
|
||||
|
||||
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", () => {
|
||||
it("accepts on/off", () => {
|
||||
expect(normalizeReasoningLevel("on")).toBe("on");
|
||||
|
||||
@@ -5,6 +5,13 @@ export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
||||
export type ElevatedMode = "off" | "ask" | "full";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
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 {
|
||||
if (!provider) {
|
||||
@@ -14,6 +21,9 @@ function normalizeProviderId(provider?: string | null): string {
|
||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||
return "zai";
|
||||
}
|
||||
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||
return "amazon-bedrock";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -130,6 +140,30 @@ export function formatXHighModelHint(): string {
|
||||
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";
|
||||
|
||||
function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined {
|
||||
|
||||
@@ -104,8 +104,8 @@ export const TelegramDirectSchema = z
|
||||
|
||||
const TelegramCustomCommandSchema = z
|
||||
.object({
|
||||
command: z.string().transform(normalizeTelegramCommandName),
|
||||
description: z.string().transform(normalizeTelegramCommandDescription),
|
||||
command: z.string().overwrite(normalizeTelegramCommandName),
|
||||
description: z.string().overwrite(normalizeTelegramCommandDescription),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import {
|
||||
@@ -529,4 +530,19 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const en: TranslationMap = {
|
||||
common: {
|
||||
version: "Version",
|
||||
health: "Health",
|
||||
ok: "OK",
|
||||
offline: "Offline",
|
||||
@@ -147,10 +146,6 @@ export const en: TranslationMap = {
|
||||
refreshAll: "Refresh All",
|
||||
terminal: "Terminal",
|
||||
},
|
||||
streamMode: {
|
||||
active: "Stream mode — values redacted",
|
||||
disable: "Disable",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "Type a command…",
|
||||
noResults: "No results",
|
||||
@@ -158,7 +153,7 @@ export const en: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "Gateway Dashboard",
|
||||
passwordPlaceholder: "optional", // pragma: allowlist secret
|
||||
passwordPlaceholder: "optional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Disconnected from gateway.",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const pt_BR: TranslationMap = {
|
||||
common: {
|
||||
version: "Versão",
|
||||
health: "Saúde",
|
||||
ok: "OK",
|
||||
offline: "Offline",
|
||||
@@ -12,7 +11,6 @@ export const pt_BR: TranslationMap = {
|
||||
disabled: "Desativado",
|
||||
na: "n/a",
|
||||
docs: "Docs",
|
||||
theme: "Tema",
|
||||
resources: "Recursos",
|
||||
search: "Pesquisar",
|
||||
},
|
||||
@@ -149,10 +147,6 @@ export const pt_BR: TranslationMap = {
|
||||
refreshAll: "Atualizar Tudo",
|
||||
terminal: "Terminal",
|
||||
},
|
||||
streamMode: {
|
||||
active: "Modo stream — valores ocultos",
|
||||
disable: "Desativar",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "Digite um comando…",
|
||||
noResults: "Sem resultados",
|
||||
@@ -160,7 +154,7 @@ export const pt_BR: TranslationMap = {
|
||||
},
|
||||
login: {
|
||||
subtitle: "Painel do Gateway",
|
||||
passwordPlaceholder: "opcional", // pragma: allowlist secret
|
||||
passwordPlaceholder: "opcional",
|
||||
},
|
||||
chat: {
|
||||
disconnected: "Desconectado do gateway.",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_CN: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康状况",
|
||||
ok: "正常",
|
||||
offline: "离线",
|
||||
@@ -12,7 +11,6 @@ export const zh_CN: TranslationMap = {
|
||||
disabled: "已禁用",
|
||||
na: "不适用",
|
||||
docs: "文档",
|
||||
theme: "主题",
|
||||
resources: "资源",
|
||||
search: "搜索",
|
||||
},
|
||||
@@ -146,10 +144,6 @@ export const zh_CN: TranslationMap = {
|
||||
refreshAll: "全部刷新",
|
||||
terminal: "终端",
|
||||
},
|
||||
streamMode: {
|
||||
active: "流模式 — 数据已隐藏",
|
||||
disable: "禁用",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "输入命令…",
|
||||
noResults: "无结果",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
||||
|
||||
export const zh_TW: TranslationMap = {
|
||||
common: {
|
||||
version: "版本",
|
||||
health: "健康狀況",
|
||||
ok: "正常",
|
||||
offline: "離線",
|
||||
@@ -12,7 +11,6 @@ export const zh_TW: TranslationMap = {
|
||||
disabled: "已禁用",
|
||||
na: "不適用",
|
||||
docs: "文檔",
|
||||
theme: "主題",
|
||||
resources: "資源",
|
||||
search: "搜尋",
|
||||
},
|
||||
@@ -146,10 +144,6 @@ export const zh_TW: TranslationMap = {
|
||||
refreshAll: "全部刷新",
|
||||
terminal: "終端",
|
||||
},
|
||||
streamMode: {
|
||||
active: "串流模式 — 數據已隱藏",
|
||||
disable: "禁用",
|
||||
},
|
||||
palette: {
|
||||
placeholder: "輸入指令…",
|
||||
noResults: "無結果",
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
@import "./styles/layout.css";
|
||||
@import "./styles/layout.mobile.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/chat.css";
|
||||
@import "./styles/config.css";
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
:root {
|
||||
/* Background - Warmer dark with depth */
|
||||
--bg: #12141a;
|
||||
--bg-accent: #14161d;
|
||||
--bg-elevated: #1a1d25;
|
||||
--bg-hover: #262a35;
|
||||
--bg-muted: #262a35;
|
||||
/* Background - Deep, rich dark with layered depth */
|
||||
--bg: #0e1015;
|
||||
--bg-accent: #13151b;
|
||||
--bg-elevated: #191c24;
|
||||
--bg-hover: #1f2330;
|
||||
--bg-muted: #1f2330;
|
||||
|
||||
/* Card / Surface - More contrast between levels */
|
||||
--card: #181b22;
|
||||
--card-foreground: #f4f4f5;
|
||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
||||
--popover: #181b22;
|
||||
--popover-foreground: #f4f4f5;
|
||||
/* Card / Surface - Clear hierarchy between levels */
|
||||
--card: #161920;
|
||||
--card-foreground: #f0f0f2;
|
||||
--card-highlight: rgba(255, 255, 255, 0.04);
|
||||
--popover: #191c24;
|
||||
--popover-foreground: #f0f0f2;
|
||||
|
||||
/* Panel */
|
||||
--panel: #12141a;
|
||||
--panel-strong: #1a1d25;
|
||||
--panel-hover: #262a35;
|
||||
--chrome: rgba(18, 20, 26, 0.95);
|
||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
||||
--panel: #0e1015;
|
||||
--panel-strong: #191c24;
|
||||
--panel-hover: #1f2330;
|
||||
--chrome: rgba(14, 16, 21, 0.96);
|
||||
--chrome-strong: rgba(14, 16, 21, 0.98);
|
||||
|
||||
/* Text - Slightly warmer */
|
||||
--text: #e4e4e7;
|
||||
--text-strong: #fafafa;
|
||||
--chat-text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
/* Text - Clean contrast */
|
||||
--text: #d4d4d8;
|
||||
--text-strong: #f4f4f5;
|
||||
--chat-text: #d4d4d8;
|
||||
--muted: #636370;
|
||||
--muted-strong: #4e4e5a;
|
||||
--muted-foreground: #636370;
|
||||
|
||||
/* Border - Subtle but defined */
|
||||
--border: #27272a;
|
||||
--border-strong: #3f3f46;
|
||||
--border-hover: #52525b;
|
||||
--input: #27272a;
|
||||
/* Border - Whisper-thin, barely there */
|
||||
--border: #1e2028;
|
||||
--border-strong: #2e3040;
|
||||
--border-hover: #3e4050;
|
||||
--input: #1e2028;
|
||||
--ring: #ff5c5c;
|
||||
|
||||
/* Accent - Punchy signature red */
|
||||
--accent: #ff5c5c;
|
||||
--accent-hover: #ff7070;
|
||||
--accent-muted: #ff5c5c;
|
||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
||||
--accent-subtle: rgba(255, 92, 92, 0.1);
|
||||
--accent-foreground: #fafafa;
|
||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
||||
--accent-glow: rgba(255, 92, 92, 0.2);
|
||||
--primary: #ff5c5c;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Secondary - Teal accent for variety */
|
||||
--secondary: #1e2028;
|
||||
--secondary-foreground: #f4f4f5;
|
||||
/* Secondary */
|
||||
--secondary: #161920;
|
||||
--secondary-foreground: #f0f0f2;
|
||||
--accent-2: #14b8a6;
|
||||
--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-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-foreground: #fafafa;
|
||||
--warn: #f59e0b;
|
||||
--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-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;
|
||||
|
||||
/* Focus - With glow */
|
||||
--focus: rgba(255, 92, 92, 0.25);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
||||
/* Focus */
|
||||
--focus: rgba(255, 92, 92, 0.2);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||
|
||||
/* Grid */
|
||||
--grid-line: rgba(255, 255, 255, 0.04);
|
||||
--grid-line: rgba(255, 255, 255, 0.03);
|
||||
|
||||
/* Theme transition */
|
||||
--theme-switch-x: 50%;
|
||||
@@ -81,111 +81,153 @@
|
||||
/* Typography */
|
||||
--mono:
|
||||
"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);
|
||||
|
||||
/* Shadows - Richer with subtle color */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||
/* Shadows - Subtle, layered depth */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.5);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
|
||||
/* Radii - Slightly larger for friendlier feel */
|
||||
/* Radii - Slightly larger for modern feel */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
--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-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 350ms;
|
||||
--duration-fast: 100ms;
|
||||
--duration-normal: 180ms;
|
||||
--duration-slow: 300ms;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme - Clean with subtle warmth */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #fafafa;
|
||||
--bg-accent: #f5f5f5;
|
||||
/* Light theme tokens apply to every light-mode family. */
|
||||
:root[data-theme-mode="light"] {
|
||||
--bg: #f8f9fa;
|
||||
--bg-accent: #f1f3f5;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-hover: #f0f0f0;
|
||||
--bg-muted: #f0f0f0;
|
||||
--bg-content: #f5f5f5;
|
||||
--bg-hover: #eceef0;
|
||||
--bg-muted: #eceef0;
|
||||
--bg-content: #f1f3f5;
|
||||
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
||||
--card-foreground: #1a1a1e;
|
||||
--card-highlight: rgba(0, 0, 0, 0.02);
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18181b;
|
||||
--popover-foreground: #1a1a1e;
|
||||
|
||||
--panel: #fafafa;
|
||||
--panel-strong: #f5f5f5;
|
||||
--panel-hover: #ebebeb;
|
||||
--chrome: rgba(250, 250, 250, 0.95);
|
||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
||||
--panel: #f8f9fa;
|
||||
--panel-strong: #f1f3f5;
|
||||
--panel-hover: #e6e8eb;
|
||||
--chrome: rgba(248, 249, 250, 0.96);
|
||||
--chrome-strong: rgba(248, 249, 250, 0.98);
|
||||
|
||||
--text: #3f3f46;
|
||||
--text-strong: #18181b;
|
||||
--chat-text: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-strong: #52525b;
|
||||
--muted-foreground: #71717a;
|
||||
--text: #3c3c43;
|
||||
--text-strong: #1a1a1e;
|
||||
--chat-text: #3c3c43;
|
||||
--muted: #8e8e93;
|
||||
--muted-strong: #636366;
|
||||
--muted-foreground: #8e8e93;
|
||||
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-hover: #a1a1aa;
|
||||
--input: #e4e4e7;
|
||||
--border: #e5e5ea;
|
||||
--border-strong: #d1d1d6;
|
||||
--border-hover: #aeaeb2;
|
||||
--input: #e5e5ea;
|
||||
|
||||
--accent: #dc2626;
|
||||
--accent-hover: #ef4444;
|
||||
--accent-muted: #dc2626;
|
||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
||||
--accent-subtle: rgba(220, 38, 38, 0.08);
|
||||
--accent-foreground: #ffffff;
|
||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
||||
--accent-glow: rgba(220, 38, 38, 0.1);
|
||||
--primary: #dc2626;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: #f4f4f5;
|
||||
--secondary-foreground: #3f3f46;
|
||||
--secondary: #f1f3f5;
|
||||
--secondary-foreground: #3c3c43;
|
||||
--accent-2: #0d9488;
|
||||
--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-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-foreground: #fafafa;
|
||||
--warn: #d97706;
|
||||
--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-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;
|
||||
|
||||
--focus: rgba(220, 38, 38, 0.2);
|
||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
||||
--focus: rgba(220, 38, 38, 0.15);
|
||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
--focus-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 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||
/* Light shadows - Subtle, clean */
|
||||
--shadow-sm: 0 1px 2px 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.08);
|
||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.1);
|
||||
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -197,8 +239,8 @@ body {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 400 14px/1.55 var(--font-body);
|
||||
letter-spacing: -0.02em;
|
||||
font: 400 13.5px/1.55 var(--font-body);
|
||||
letter-spacing: -0.01em;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -267,10 +309,10 @@ select {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
/* Scrollbar styling - Minimal, barely visible */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -278,12 +320,12 @@ select {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-strong);
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
/* Chat Group Layout - default (assistant/other on left) */
|
||||
.chat-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
margin-left: 4px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
@@ -54,6 +54,52 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -83,22 +129,24 @@
|
||||
|
||||
/* Avatar Styles */
|
||||
.chat-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: var(--panel-strong);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-end; /* Align with last message in group */
|
||||
margin-bottom: 4px; /* Optical alignment */
|
||||
align-self: flex-end;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.chat-avatar.assistant {
|
||||
@@ -127,14 +175,14 @@ img.chat-avatar {
|
||||
.chat-bubble {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 14px;
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
background var(--duration-fast) ease-out,
|
||||
border-color var(--duration-fast) ease-out;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -244,7 +292,7 @@ img.chat-avatar {
|
||||
}
|
||||
|
||||
/* Light mode: restore borders */
|
||||
:root[data-theme="light"] .chat-bubble {
|
||||
:root[data-theme-mode="light"] .chat-bubble {
|
||||
border-color: var(--border);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
}
|
||||
@@ -259,7 +307,7 @@ img.chat-avatar {
|
||||
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);
|
||||
background: rgba(251, 146, 60, 0.12);
|
||||
}
|
||||
@@ -298,3 +346,125 @@ img.chat-avatar {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -219,17 +219,17 @@
|
||||
}
|
||||
|
||||
/* Light theme attachment overrides */
|
||||
:root[data-theme="light"] .chat-attachments {
|
||||
:root[data-theme-mode="light"] .chat-attachments {
|
||||
background: #f8fafc;
|
||||
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);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .chat-attachment__remove {
|
||||
:root[data-theme-mode="light"] .chat-attachment__remove {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@
|
||||
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%);
|
||||
}
|
||||
|
||||
@@ -322,6 +322,340 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -363,7 +697,7 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -373,34 +707,34 @@
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
:root[data-theme-mode="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
:root[data-theme-mode="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Light theme icon button overrides */
|
||||
:root[data-theme="light"] .btn--icon {
|
||||
:root[data-theme-mode="light"] .btn--icon {
|
||||
background: #ffffff;
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .btn--icon:hover {
|
||||
:root[data-theme-mode="light"] .btn--icon:hover {
|
||||
background: #ffffff;
|
||||
border-color: var(--border-strong);
|
||||
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);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
@@ -438,7 +772,7 @@
|
||||
}
|
||||
|
||||
/* 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);
|
||||
border-color: rgba(16, 24, 40, 0.15);
|
||||
}
|
||||
@@ -479,3 +813,117 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
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);
|
||||
background: rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
@@ -97,24 +97,24 @@
|
||||
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);
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
: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);
|
||||
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);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
/* Tool Card Styles */
|
||||
.chat-tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
margin-top: 6px;
|
||||
background: var(--card);
|
||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background 150ms ease-out;
|
||||
/* Fixed max-height to ensure cards don't expand too much */
|
||||
border-color var(--duration-fast) ease-out,
|
||||
background var(--duration-fast) ease-out;
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -154,6 +152,265 @@
|
||||
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 */
|
||||
.chat-reading-indicator {
|
||||
background: transparent;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
||||
--shell-pad: 16px;
|
||||
--shell-gap: 16px;
|
||||
--shell-nav-width: 220px;
|
||||
--shell-topbar-height: 56px;
|
||||
--shell-nav-rail-width: 72px;
|
||||
--shell-topbar-height: 52px;
|
||||
--shell-focus-duration: 200ms;
|
||||
--shell-focus-ease: var(--ease-out);
|
||||
height: 100vh;
|
||||
@@ -17,7 +18,7 @@
|
||||
"topbar topbar"
|
||||
"nav content";
|
||||
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);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -41,7 +42,7 @@
|
||||
}
|
||||
|
||||
.shell--nav-collapsed {
|
||||
grid-template-columns: 0px minmax(0, 1fr);
|
||||
grid-template-columns: var(--shell-nav-rail-width) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.shell--chat-focus {
|
||||
@@ -84,7 +85,9 @@
|
||||
padding: 0 20px;
|
||||
height: var(--shell-topbar-height);
|
||||
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 {
|
||||
@@ -113,12 +116,12 @@
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -131,11 +134,11 @@
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.1;
|
||||
@@ -143,10 +146,10 @@
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -179,93 +182,389 @@
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
.topbar-status .theme-orb__trigger {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-status .theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
/* Topbar search trigger */
|
||||
.topbar-search {
|
||||
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;
|
||||
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-x: hidden;
|
||||
padding: 16px 12px;
|
||||
background: var(--bg);
|
||||
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;
|
||||
padding: 4px 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shell--chat-focus .nav {
|
||||
width: 0;
|
||||
.sidebar--collapsed .sidebar-nav {
|
||||
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;
|
||||
border-width: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
margin: 0 auto;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.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;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Nav collapse toggle */
|
||||
.nav-collapse-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--duration-fast) ease,
|
||||
border-color var(--duration-fast) ease;
|
||||
margin-bottom: 16px;
|
||||
border-color var(--duration-fast) ease,
|
||||
color var(--duration-fast) ease;
|
||||
margin-bottom: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--muted);
|
||||
transition: color var(--duration-fast) ease;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-collapse-toggle__icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -274,14 +573,14 @@
|
||||
}
|
||||
|
||||
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
||||
color: var(--text);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Nav groups */
|
||||
.nav-group {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 12px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.nav-group:last-child {
|
||||
@@ -297,53 +596,67 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Nav label */
|
||||
.nav-label {
|
||||
.nav-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: var(--radius-sm);
|
||||
transition:
|
||||
color var(--duration-fast) ease,
|
||||
background var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
.nav-group__label:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.nav-label--static {
|
||||
.nav-group__label--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-label--static:hover {
|
||||
.nav-group__label--static:hover {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-label__text {
|
||||
.nav-group__label-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-label__chevron {
|
||||
.nav-group__chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
opacity: 0.5;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -353,8 +666,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
@@ -368,19 +681,19 @@
|
||||
}
|
||||
|
||||
.nav-item__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--duration-fast) ease;
|
||||
}
|
||||
|
||||
.nav-item__icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5px;
|
||||
@@ -390,7 +703,7 @@
|
||||
|
||||
.nav-item__text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-weight: 450;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -401,26 +714,91 @@
|
||||
}
|
||||
|
||||
.nav-item:hover .nav-item__icon {
|
||||
opacity: 1;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
.nav-item.active,
|
||||
.nav-item--active {
|
||||
color: var(--text-strong);
|
||||
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;
|
||||
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 {
|
||||
grid-area: content;
|
||||
padding: 12px 16px 32px;
|
||||
padding: 16px 20px 32px;
|
||||
display: block;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -428,10 +806,10 @@
|
||||
}
|
||||
|
||||
.content>*+* {
|
||||
margin-top: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .content {
|
||||
:root[data-theme-mode="light"] .content {
|
||||
background: var(--bg-content);
|
||||
}
|
||||
|
||||
@@ -473,19 +851,19 @@
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.035em;
|
||||
line-height: 1.15;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.2;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.page-sub {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
margin-top: 6px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 4px;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
@@ -577,18 +955,6 @@
|
||||
"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 {
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
|
||||
@@ -2,45 +2,102 @@
|
||||
Mobile Layout
|
||||
=========================================== */
|
||||
|
||||
/* Tablet: Horizontal nav */
|
||||
/* Tablet and smaller: collapse the left nav into a horizontal rail. */
|
||||
@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;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 8px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
.sidebar-nav::-webkit-scrollbar,
|
||||
.sidebar--collapsed .sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group,
|
||||
.nav-group__items,
|
||||
.sidebar--collapsed .nav-group,
|
||||
.sidebar--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
.sidebar-nav .nav-group__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-group--collapsed .nav-group__items {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
.nav-item,
|
||||
.sidebar--collapsed .nav-item {
|
||||
margin: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-md);
|
||||
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;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
.shell-nav {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
.sidebar-header {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
.sidebar-nav {
|
||||
gap: 6px;
|
||||
padding: 6px 8px 6px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
@@ -239,6 +289,26 @@
|
||||
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 {
|
||||
border-radius: var(--radius-md);
|
||||
@@ -288,16 +358,10 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
--theme-item: 24px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 3px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.theme-orb__trigger {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,10 +379,6 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
@@ -361,14 +421,9 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
--theme-item: 22px;
|
||||
--theme-gap: 2px;
|
||||
--theme-pad: 2px;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
.theme-orb__trigger {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,25 +3,33 @@ import { scheduleChatScroll } from "./app-scroll.ts";
|
||||
import { setLastActiveSessionKey } from "./app-settings.ts";
|
||||
import { resetToolStream } from "./app-tool-stream.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 { loadSessions } from "./controllers/sessions.ts";
|
||||
import type { GatewayHelloOk } from "./gateway.ts";
|
||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||
import { normalizeBasePath } from "./navigation.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
|
||||
export type ChatHost = {
|
||||
client: GatewayBrowserClient | null;
|
||||
chatMessages: unknown[];
|
||||
chatStream: string | null;
|
||||
connected: boolean;
|
||||
chatMessage: string;
|
||||
chatAttachments: ChatAttachment[];
|
||||
chatQueue: ChatQueueItem[];
|
||||
chatRunId: string | null;
|
||||
chatSending: boolean;
|
||||
lastError?: string | null;
|
||||
sessionKey: string;
|
||||
basePath: string;
|
||||
hello: GatewayHelloOk | null;
|
||||
chatAvatarUrl: string | null;
|
||||
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;
|
||||
@@ -73,6 +81,7 @@ function enqueueChatMessage(
|
||||
text: string,
|
||||
attachments?: ChatAttachment[],
|
||||
refreshSessions?: boolean,
|
||||
localCommand?: { args: string; name: string },
|
||||
) {
|
||||
const trimmed = text.trim();
|
||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||
@@ -87,6 +96,8 @@ function enqueueChatMessage(
|
||||
createdAt: Date.now(),
|
||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||
refreshSessions,
|
||||
localCommandArgs: localCommand?.args,
|
||||
localCommandName: localCommand?.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -143,12 +154,25 @@ async function flushChatQueue(host: ChatHost) {
|
||||
return;
|
||||
}
|
||||
host.chatQueue = rest;
|
||||
const ok = await sendChatMessageNow(host, next.text, {
|
||||
let ok = false;
|
||||
try {
|
||||
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) {
|
||||
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 hasAttachments = attachmentsToSend.length > 0;
|
||||
|
||||
// Allow sending with just attachments (no message text required)
|
||||
if (!message && !hasAttachments) {
|
||||
return;
|
||||
}
|
||||
@@ -180,10 +203,35 @@ export async function handleSendChat(
|
||||
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);
|
||||
if (messageOverride == null) {
|
||||
host.chatMessage = "";
|
||||
// Clear attachments when sending
|
||||
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 }) {
|
||||
await Promise.all([
|
||||
loadChatHistory(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),
|
||||
]);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
|
||||
import type { OpenClawApp } from "./app.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 { loadChatHistory } from "./controllers/chat.ts";
|
||||
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
parseExecApprovalResolved,
|
||||
removeExecApproval,
|
||||
} from "./controllers/exec-approval.ts";
|
||||
import { loadHealthState } from "./controllers/health.ts";
|
||||
import { loadNodes } from "./controllers/nodes.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import {
|
||||
@@ -39,7 +40,7 @@ import type { UiSettings } from "./storage.ts";
|
||||
import type {
|
||||
AgentsListResult,
|
||||
PresenceEntry,
|
||||
HealthSnapshot,
|
||||
HealthSummary,
|
||||
StatusSummary,
|
||||
UpdateAvailable,
|
||||
} from "./types.ts";
|
||||
@@ -81,10 +82,10 @@ type GatewayHost = {
|
||||
agentsLoading: boolean;
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsError: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null;
|
||||
debugHealth: HealthSnapshot | null;
|
||||
healthLoading: boolean;
|
||||
healthResult: HealthSummary | null;
|
||||
healthError: string | null;
|
||||
debugHealth: HealthSummary | null;
|
||||
assistantName: string;
|
||||
assistantAvatar: string | null;
|
||||
assistantAgentId: string | null;
|
||||
@@ -221,7 +222,7 @@ export function connectGateway(host: GatewayHost) {
|
||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||
void loadAssistantIdentity(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 loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||
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 },
|
||||
...host.eventLogBuffer,
|
||||
].slice(0, 250);
|
||||
if (host.tab === "debug") {
|
||||
if (host.tab === "debug" || host.tab === "overview") {
|
||||
host.eventLog = host.eventLogBuffer;
|
||||
}
|
||||
|
||||
@@ -406,7 +407,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
const snapshot = hello.snapshot as
|
||||
| {
|
||||
presence?: PresenceEntry[];
|
||||
health?: HealthSnapshot;
|
||||
health?: HealthSummary;
|
||||
sessionDefaults?: SessionDefaultsSnapshot;
|
||||
updateAvailable?: UpdateAvailable;
|
||||
}
|
||||
@@ -416,6 +417,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||
}
|
||||
if (snapshot?.health) {
|
||||
host.debugHealth = snapshot.health;
|
||||
host.healthResult = snapshot.health;
|
||||
}
|
||||
if (snapshot?.sessionDefaults) {
|
||||
applySessionDefaults(host, snapshot.sessionDefaults);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { OpenClawApp } from "./app.ts";
|
||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||
import { loadSessions } from "./controllers/sessions.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.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";
|
||||
|
||||
type SessionDefaultsSnapshot = {
|
||||
@@ -49,10 +51,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
|
||||
|
||||
export function renderTab(state: AppViewState, tab: Tab) {
|
||||
const href = pathForTab(tab, state.basePath);
|
||||
const isActive = state.tab === tab;
|
||||
const collapsed = state.settings.navCollapsed;
|
||||
return html`
|
||||
<a
|
||||
href=${href}
|
||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
||||
class="nav-item ${isActive ? "nav-item--active" : ""}"
|
||||
@click=${(event: MouseEvent) => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
@@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
||||
title=${titleForTab(tab)}
|
||||
>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -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) {
|
||||
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
||||
const hideCron = state.sessionsHideCron ?? true;
|
||||
const hiddenCronCount = hideCron
|
||||
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
|
||||
: 0;
|
||||
const sessionOptions = resolveSessionOptions(
|
||||
state.sessionKey,
|
||||
state.sessionsResult,
|
||||
mainSessionKey,
|
||||
hideCron,
|
||||
);
|
||||
const disableThinkingToggle = state.onboarding;
|
||||
const disableFocusToggle = state.onboarding;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||
// Refresh icon
|
||||
const refreshIcon = html`
|
||||
<svg
|
||||
width="18"
|
||||
@@ -174,43 +207,6 @@ export function renderChatControls(state: AppViewState) {
|
||||
`;
|
||||
return html`
|
||||
<div class="chat-controls">
|
||||
<label class="field chat-controls__session">
|
||||
<select
|
||||
.value=${state.sessionKey}
|
||||
?disabled=${!state.connected}
|
||||
@change=${(e: Event) => {
|
||||
const next = (e.target as HTMLSelectElement).value;
|
||||
state.sessionKey = next;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||
state.chatRunId = null;
|
||||
(state as unknown as OpenClawApp).resetToolStream();
|
||||
(state as unknown as OpenClawApp).resetChatScroll();
|
||||
state.applySettings({
|
||||
...state.settings,
|
||||
sessionKey: next,
|
||||
lastActiveSessionKey: next,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
next,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
}}
|
||||
>
|
||||
${repeat(
|
||||
sessionOptions,
|
||||
(entry) => entry.key,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key} title=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="btn btn--sm btn--icon"
|
||||
?disabled=${state.chatLoading || !state.connected}
|
||||
@@ -291,23 +287,38 @@ export function renderChatControls(state: AppViewState) {
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveMainSessionKey(
|
||||
hello: AppViewState["hello"],
|
||||
sessions: SessionsListResult | null,
|
||||
): string | null {
|
||||
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
||||
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
||||
if (mainSessionKey) {
|
||||
return mainSessionKey;
|
||||
function switchChatSession(state: AppViewState, nextSessionKey: string) {
|
||||
state.sessionKey = nextSessionKey;
|
||||
state.chatMessage = "";
|
||||
state.chatStream = null;
|
||||
// P1: Clear queued chat items from the previous session
|
||||
(state as unknown as { chatQueue: unknown[] }).chatQueue = [];
|
||||
(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: nextSessionKey,
|
||||
lastActiveSessionKey: nextSessionKey,
|
||||
});
|
||||
void state.loadAssistantIdentity();
|
||||
syncUrlWithSessionKey(
|
||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
||||
nextSessionKey,
|
||||
true,
|
||||
);
|
||||
void loadChatHistory(state as unknown as ChatState);
|
||||
void refreshSessionOptions(state);
|
||||
}
|
||||
const mainKey = snapshot?.sessionDefaults?.mainKey?.trim();
|
||||
if (mainKey) {
|
||||
return mainKey;
|
||||
}
|
||||
if (sessions?.sessions?.some((row) => row.key === "main")) {
|
||||
return "main";
|
||||
}
|
||||
return null;
|
||||
|
||||
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 ────────────────────────────── */
|
||||
@@ -431,51 +442,75 @@ export function isCronSessionKey(key: string): boolean {
|
||||
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,
|
||||
sessions: SessionsListResult | null,
|
||||
mainSessionKey?: string | null,
|
||||
hideCron = false,
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
const options: Array<{ key: string; displayName?: string }> = [];
|
||||
): SessionOptionGroup[] {
|
||||
const rows = sessions?.sessions ?? [];
|
||||
const hideCron = state.sessionsHideCron ?? true;
|
||||
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
|
||||
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);
|
||||
const seenKeys = new Set<string>();
|
||||
const groups = new Map<string, SessionOptionGroup>();
|
||||
const ensureGroup = (groupId: string, label: string): SessionOptionGroup => {
|
||||
const existing = groups.get(groupId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: SessionOptionGroup = {
|
||||
id: groupId,
|
||||
label,
|
||||
options: [],
|
||||
};
|
||||
groups.set(groupId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
// Add main session key first
|
||||
if (mainSessionKey) {
|
||||
seen.add(mainSessionKey);
|
||||
options.push({
|
||||
key: mainSessionKey,
|
||||
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add current session key next — always include it even if it's a cron session,
|
||||
// so the active session is never silently dropped from the select.
|
||||
if (!seen.has(sessionKey)) {
|
||||
seen.add(sessionKey);
|
||||
options.push({
|
||||
key: sessionKey,
|
||||
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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),
|
||||
});
|
||||
addOption(row.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
addOption(sessionKey);
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
||||
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode));
|
||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
const context: ThemeTransitionContext = { element };
|
||||
if (event.clientX || event.clientY) {
|
||||
context.pointerClientX = event.clientX;
|
||||
context.pointerClientY = event.clientY;
|
||||
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;
|
||||
}
|
||||
state.setThemeMode(next, context);
|
||||
|
||||
function resolveSessionScopedOptionLabel(
|
||||
key: string,
|
||||
row?: SessionsListResult["sessions"][number],
|
||||
rest?: string,
|
||||
) {
|
||||
const base = rest?.trim() || key;
|
||||
if (!row) {
|
||||
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;
|
||||
}
|
||||
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`
|
||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
||||
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
||||
<span class="theme-toggle__indicator"></span>
|
||||
<div class="topbar-theme-mode" role="group" aria-label="Color mode">
|
||||
${THEME_MODE_OPTIONS.map(
|
||||
(opt) => html`
|
||||
<button
|
||||
class="theme-toggle__button ${state.themeMode === "system" ? "active" : ""}"
|
||||
@click=${applyTheme("system")}
|
||||
aria-pressed=${state.themeMode === "system"}
|
||||
aria-label="System theme"
|
||||
title="System"
|
||||
type="button"
|
||||
class="topbar-theme-mode__btn ${opt.id === state.themeMode ? "topbar-theme-mode__btn--active" : ""}"
|
||||
title=${opt.label}
|
||||
aria-label="Color mode: ${opt.label}"
|
||||
aria-pressed=${opt.id === state.themeMode}
|
||||
@click=${(e: Event) => applyMode(opt.id, e)}
|
||||
>
|
||||
${renderMonitorIcon()}
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSunIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"></circle>
|
||||
<path d="M12 2v2"></path>
|
||||
<path d="M12 20v2"></path>
|
||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
||||
<path d="M2 12h2"></path>
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>
|
||||
`;
|
||||
export function renderThemeToggle(state: AppViewState) {
|
||||
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
|
||||
orb.classList.toggle("theme-orb--open", nextOpen);
|
||||
const trigger = orb.querySelector<HTMLButtonElement>(".theme-orb__trigger");
|
||||
const menu = orb.querySelector<HTMLElement>(".theme-orb__menu");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false");
|
||||
}
|
||||
if (menu) {
|
||||
menu.setAttribute("aria-hidden", nextOpen ? "false" : "true");
|
||||
}
|
||||
};
|
||||
|
||||
function renderMoonIcon() {
|
||||
return html`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
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>
|
||||
</svg>
|
||||
`;
|
||||
const toggleOpen = (e: Event) => {
|
||||
const orb = (e.currentTarget as HTMLElement).closest<HTMLElement>(".theme-orb");
|
||||
if (!orb) {
|
||||
return;
|
||||
}
|
||||
const isOpen = orb.classList.contains("theme-orb--open");
|
||||
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`
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
||||
<line x1="8" x2="16" y1="21" y2="21"></line>
|
||||
<line x1="12" x2="12" y1="17" y2="21"></line>
|
||||
</svg>
|
||||
<div class="theme-orb" aria-label="Theme">
|
||||
<button
|
||||
type="button"
|
||||
class="theme-orb__trigger"
|
||||
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
@@ -1,3 +1,4 @@
|
||||
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
|
||||
import { refreshChat } from "./app-chat.ts";
|
||||
import {
|
||||
startLogsPolling,
|
||||
@@ -9,15 +10,10 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
|
||||
import type { OpenClawApp } from "./app.ts";
|
||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.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 { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
||||
import {
|
||||
loadCronJobs,
|
||||
loadCronModelSuggestions,
|
||||
loadCronRuns,
|
||||
loadCronStatus,
|
||||
} from "./controllers/cron.ts";
|
||||
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
|
||||
import { loadDebug } from "./controllers/debug.ts";
|
||||
import { loadDevices } from "./controllers/devices.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 { loadSessions } from "./controllers/sessions.ts";
|
||||
import { loadSkills } from "./controllers/skills.ts";
|
||||
import { loadUsage } from "./controllers/usage.ts";
|
||||
import {
|
||||
inferBasePathFromPathname,
|
||||
normalizeBasePath,
|
||||
@@ -36,15 +33,9 @@ import {
|
||||
} from "./navigation.ts";
|
||||
import { saveSettings, type UiSettings } from "./storage.ts";
|
||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
|
||||
import {
|
||||
colorSchemeForTheme,
|
||||
dataThemeForTheme,
|
||||
resolveTheme,
|
||||
type ResolvedTheme,
|
||||
type ThemeMode,
|
||||
type ThemeName,
|
||||
} from "./theme.ts";
|
||||
import type { AgentsListResult } from "./types.ts";
|
||||
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||
import type { AgentsListResult, AttentionItem } from "./types.ts";
|
||||
import { resetChatViewState } from "./views/chat.ts";
|
||||
|
||||
type SettingsHost = {
|
||||
settings: UiSettings;
|
||||
@@ -64,9 +55,8 @@ type SettingsHost = {
|
||||
agentsList?: AgentsListResult | null;
|
||||
agentsSelectedId?: string | null;
|
||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||
themeMedia: MediaQueryList | null;
|
||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
||||
pendingGatewayUrl?: string | null;
|
||||
systemThemeCleanup?: (() => void) | null;
|
||||
pendingGatewayToken?: string | null;
|
||||
};
|
||||
|
||||
@@ -176,17 +166,17 @@ export function setTab(host: SettingsHost, next: Tab) {
|
||||
}
|
||||
|
||||
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
|
||||
const resolved = resolveTheme(next, host.themeMode);
|
||||
const applyTheme = () => {
|
||||
host.theme = next;
|
||||
applySettings(host, { ...host.settings, theme: next });
|
||||
applyResolvedTheme(host, resolveTheme(next, host.themeMode));
|
||||
};
|
||||
startThemeTransition({
|
||||
nextTheme: resolveTheme(next, host.themeMode),
|
||||
nextTheme: resolved,
|
||||
applyTheme,
|
||||
context,
|
||||
currentTheme: host.themeResolved,
|
||||
});
|
||||
syncSystemThemeListener(host);
|
||||
}
|
||||
|
||||
export function setThemeMode(
|
||||
@@ -194,17 +184,17 @@ export function setThemeMode(
|
||||
next: ThemeMode,
|
||||
context?: ThemeTransitionContext,
|
||||
) {
|
||||
const applyTheme = () => {
|
||||
host.themeMode = next;
|
||||
const resolved = resolveTheme(host.theme, next);
|
||||
const applyMode = () => {
|
||||
applySettings(host, { ...host.settings, themeMode: next });
|
||||
applyResolvedTheme(host, resolveTheme(host.theme, next));
|
||||
};
|
||||
startThemeTransition({
|
||||
nextTheme: resolveTheme(host.theme, next),
|
||||
applyTheme,
|
||||
nextTheme: resolved,
|
||||
applyTheme: applyMode,
|
||||
context,
|
||||
currentTheme: host.themeResolved,
|
||||
});
|
||||
syncSystemThemeListener(host);
|
||||
}
|
||||
|
||||
export async function refreshActiveTab(host: SettingsHost) {
|
||||
@@ -228,7 +218,6 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
}
|
||||
if (host.tab === "agents") {
|
||||
await loadAgents(host as unknown as OpenClawApp);
|
||||
await loadToolsCatalog(host as unknown as OpenClawApp);
|
||||
await loadConfig(host as unknown as OpenClawApp);
|
||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||
if (agentIds.length > 0) {
|
||||
@@ -262,7 +251,14 @@ export async function refreshActiveTab(host: SettingsHost) {
|
||||
!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 loadConfig(host as unknown as OpenClawApp);
|
||||
}
|
||||
@@ -289,9 +285,19 @@ export function inferBasePath() {
|
||||
}
|
||||
|
||||
export function syncThemeWithSettings(host: SettingsHost) {
|
||||
host.theme = host.settings.theme;
|
||||
host.themeMode = host.settings.themeMode;
|
||||
host.theme = host.settings.theme ?? "claw";
|
||||
host.themeMode = host.settings.themeMode ?? "system";
|
||||
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) {
|
||||
@@ -300,45 +306,45 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
||||
return;
|
||||
}
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = dataThemeForTheme(resolved);
|
||||
root.style.colorScheme = colorSchemeForTheme(resolved);
|
||||
const themeMode = resolved.endsWith("light") ? "light" : "dark";
|
||||
root.dataset.theme = resolved;
|
||||
root.dataset.themeMode = themeMode;
|
||||
root.style.colorScheme = themeMode;
|
||||
}
|
||||
|
||||
export function attachThemeListener(host: SettingsHost) {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
function syncSystemThemeListener(host: SettingsHost) {
|
||||
// Clean up existing listener if mode is not "system"
|
||||
if (host.themeMode !== "system") {
|
||||
host.systemThemeCleanup?.();
|
||||
host.systemThemeCleanup = null;
|
||||
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") {
|
||||
return;
|
||||
}
|
||||
applyResolvedTheme(host, resolveTheme(host.theme, event.matches ? "dark" : "light"));
|
||||
applyResolvedTheme(host, resolveTheme(host.theme, "system"));
|
||||
};
|
||||
if (typeof host.themeMedia.addEventListener === "function") {
|
||||
host.themeMedia.addEventListener("change", host.themeMediaHandler);
|
||||
if (typeof mql.addEventListener === "function") {
|
||||
mql.addEventListener("change", onChange);
|
||||
host.systemThemeCleanup = () => mql.removeEventListener("change", onChange);
|
||||
return;
|
||||
}
|
||||
const legacy = host.themeMedia as MediaQueryList & {
|
||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.addListener(host.themeMediaHandler);
|
||||
if (typeof mql.addListener === "function") {
|
||||
mql.addListener(onChange);
|
||||
host.systemThemeCleanup = () => mql.removeListener(onChange);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -382,9 +388,16 @@ function applyTabSelection(
|
||||
next: Tab,
|
||||
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
|
||||
) {
|
||||
const prev = host.tab;
|
||||
if (host.tab !== next) {
|
||||
host.tab = next;
|
||||
}
|
||||
|
||||
// Cleanup chat module state when navigating away from chat
|
||||
if (prev === "chat" && next !== "chat") {
|
||||
resetChatViewState();
|
||||
}
|
||||
|
||||
if (next === "chat") {
|
||||
host.chatHasAutoScrolled = false;
|
||||
}
|
||||
@@ -447,13 +460,143 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
|
||||
}
|
||||
|
||||
export async function loadOverview(host: SettingsHost) {
|
||||
await Promise.all([
|
||||
loadChannels(host as unknown as OpenClawApp, false),
|
||||
loadPresence(host as unknown as OpenClawApp),
|
||||
loadSessions(host as unknown as OpenClawApp),
|
||||
loadCronStatus(host as unknown as OpenClawApp),
|
||||
loadDebug(host as unknown as OpenClawApp),
|
||||
const app = host as unknown as OpenClawApp;
|
||||
await Promise.allSettled([
|
||||
loadChannels(app, false),
|
||||
loadPresence(app),
|
||||
loadSessions(app),
|
||||
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) {
|
||||
@@ -465,18 +608,12 @@ export async function loadChannelsTab(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([
|
||||
loadChannels(host as unknown as OpenClawApp, false),
|
||||
loadCronStatus(cronHost),
|
||||
loadCronJobs(cronHost),
|
||||
loadCronModelSuggestions(cronHost),
|
||||
loadChannels(app, false),
|
||||
loadCronStatus(app),
|
||||
loadCronJobs(app),
|
||||
loadCronRuns(app, activeCronJobId),
|
||||
]);
|
||||
if (cronHost.cronRunsScope === "all") {
|
||||
await loadCronRuns(cronHost, null);
|
||||
return;
|
||||
}
|
||||
if (cronHost.cronRunsJobId) {
|
||||
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ import {
|
||||
} from "./app-tool-stream.ts";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||
import { exportChatMarkdown } from "./chat/export.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 { ExecApprovalRequest } from "./controllers/exec-approval.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 { Tab } from "./navigation.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 {
|
||||
AgentsListResult,
|
||||
AgentsFilesListResult,
|
||||
@@ -72,16 +72,17 @@ import type {
|
||||
CronJob,
|
||||
CronRunLogEntry,
|
||||
CronStatus,
|
||||
HealthSnapshot,
|
||||
HealthSummary,
|
||||
LogEntry,
|
||||
LogLevel,
|
||||
ModelCatalogEntry,
|
||||
PresenceEntry,
|
||||
ChannelsStatusSnapshot,
|
||||
SessionsListResult,
|
||||
SkillStatusReport,
|
||||
ToolsCatalogResult,
|
||||
StatusSummary,
|
||||
NostrProfile,
|
||||
ToolsCatalogResult,
|
||||
} from "./types.ts";
|
||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
|
||||
import { generateUUID } from "./uuid.ts";
|
||||
@@ -121,12 +122,15 @@ export class OpenClawApp extends LitElement {
|
||||
}
|
||||
}
|
||||
@state() password = "";
|
||||
@state() loginShowGatewayToken = false;
|
||||
@state() loginShowGatewayPassword = false;
|
||||
@state() tab: Tab = "chat";
|
||||
@state() onboarding = resolveOnboardingMode();
|
||||
@state() connected = false;
|
||||
@state() theme: ThemeName = this.settings.theme;
|
||||
@state() themeMode: ThemeMode = this.settings.themeMode;
|
||||
@state() theme: ThemeName = this.settings.theme ?? "claw";
|
||||
@state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
|
||||
@state() themeResolved: ResolvedTheme = "dark";
|
||||
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
|
||||
@state() hello: GatewayHelloOk | null = null;
|
||||
@state() lastError: string | null = null;
|
||||
@state() lastErrorCode: string | null = null;
|
||||
@@ -157,6 +161,9 @@ export class OpenClawApp extends LitElement {
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
@state() chatAttachments: ChatAttachment[] = [];
|
||||
@state() chatManualRefreshInFlight = false;
|
||||
|
||||
onSlashAction?: (action: string) => void;
|
||||
|
||||
// Sidebar state for tool output viewing
|
||||
@state() sidebarOpen = false;
|
||||
@state() sidebarContent: string | null = null;
|
||||
@@ -203,6 +210,26 @@ export class OpenClawApp extends LitElement {
|
||||
@state() configSearchQuery = "";
|
||||
@state() configActiveSection: 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() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||
@@ -252,6 +279,12 @@ export class OpenClawApp extends LitElement {
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
@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() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||
@@ -326,7 +359,7 @@ export class OpenClawApp extends LitElement {
|
||||
@state() cronStatus: CronStatus | null = null;
|
||||
@state() cronError: string | null = null;
|
||||
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
|
||||
@state() cronFieldErrors: CronFieldErrors = {};
|
||||
@state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {};
|
||||
@state() cronEditingJobId: string | null = null;
|
||||
@state() cronRunsJobId: string | null = null;
|
||||
@state() cronRunsLoadingMore = false;
|
||||
@@ -346,6 +379,16 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
@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() skillsReport: SkillStatusReport | null = null;
|
||||
@state() skillsError: string | null = null;
|
||||
@@ -354,10 +397,14 @@ export class OpenClawApp extends LitElement {
|
||||
@state() skillsBusyKey: string | null = null;
|
||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||
|
||||
@state() healthLoading = false;
|
||||
@state() healthResult: HealthSummary | null = null;
|
||||
@state() healthError: string | null = null;
|
||||
|
||||
@state() debugLoading = false;
|
||||
@state() debugStatus: StatusSummary | null = null;
|
||||
@state() debugHealth: HealthSnapshot | null = null;
|
||||
@state() debugModels: unknown[] = [];
|
||||
@state() debugHealth: HealthSummary | null = null;
|
||||
@state() debugModels: ModelCatalogEntry[] = [];
|
||||
@state() debugHeartbeat: unknown = null;
|
||||
@state() debugCallMethod = "";
|
||||
@state() debugCallParams = "{}";
|
||||
@@ -396,9 +443,17 @@ export class OpenClawApp extends LitElement {
|
||||
basePath = "";
|
||||
private popStateHandler = () =>
|
||||
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 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() {
|
||||
return this;
|
||||
@@ -406,6 +461,20 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -414,6 +483,7 @@ export class OpenClawApp extends LitElement {
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("keydown", this.globalKeydownHandler);
|
||||
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
@@ -475,6 +545,7 @@ export class OpenClawApp extends LitElement {
|
||||
|
||||
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
|
||||
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
|
||||
this.themeOrder = this.buildThemeOrder(next);
|
||||
}
|
||||
|
||||
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() {
|
||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||
}
|
||||
|
||||
5
ui/src/ui/chat/attachment-support.ts
Normal file
5
ui/src/ui/chat/attachment-support.ts
Normal 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/");
|
||||
}
|
||||
@@ -44,6 +44,10 @@ export class DeletedMessages {
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,8 @@
|
||||
import { extractTextCached } from "./message-extract.ts";
|
||||
|
||||
/**
|
||||
* Export chat history as markdown file.
|
||||
*/
|
||||
export function escapeHtmlInMarkdown(text: string): string {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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 {
|
||||
const markdown = buildChatMarkdown(messages, assistantName);
|
||||
if (!markdown) {
|
||||
@@ -62,7 +12,23 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string):
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = buildChatExportFilename(assistantName);
|
||||
link.download = `chat-${assistantName}-${Date.now()}.md`;
|
||||
link.click();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { openExternalUrlSafe } from "../open-external-url.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 {
|
||||
extractTextCached,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
formatReasoningMarkdown,
|
||||
} from "./message-extract.ts";
|
||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
|
||||
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts";
|
||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
|
||||
|
||||
type ImageBlock = {
|
||||
@@ -56,10 +59,10 @@ function extractImages(message: unknown): ImageBlock[] {
|
||||
return images;
|
||||
}
|
||||
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
<div class="chat-group-messages">
|
||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||
<span class="chat-reading-indicator__dots">
|
||||
@@ -76,6 +79,7 @@ export function renderStreamingGroup(
|
||||
startedAt: number,
|
||||
onOpenSidebar?: (content: string) => void,
|
||||
assistant?: AssistantIdentity,
|
||||
basePath?: string,
|
||||
) {
|
||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
@@ -85,7 +89,7 @@ export function renderStreamingGroup(
|
||||
|
||||
return html`
|
||||
<div class="chat-group assistant">
|
||||
${renderAvatar("assistant", assistant)}
|
||||
${renderAvatar("assistant", assistant, basePath)}
|
||||
<div class="chat-group-messages">
|
||||
${renderGroupedMessage(
|
||||
{
|
||||
@@ -112,6 +116,9 @@ export function renderMessageGroup(
|
||||
showReasoning: boolean;
|
||||
assistantName?: string;
|
||||
assistantAvatar?: string | null;
|
||||
basePath?: string;
|
||||
contextWindow?: number | null;
|
||||
onDelete?: () => void;
|
||||
},
|
||||
) {
|
||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||
@@ -122,20 +129,35 @@ export function renderMessageGroup(
|
||||
? (userLabel ?? "You")
|
||||
: normalizedRole === "assistant"
|
||||
? assistantName
|
||||
: normalizedRole === "tool"
|
||||
? "Tool"
|
||||
: normalizedRole;
|
||||
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([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
// Aggregate usage/cost/model across all messages in the group
|
||||
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
|
||||
|
||||
return html`
|
||||
<div class="chat-group ${roleClass}">
|
||||
${renderAvatar(group.role, {
|
||||
${renderAvatar(
|
||||
group.role,
|
||||
{
|
||||
name: assistantName,
|
||||
avatar: opts.assistantAvatar ?? null,
|
||||
})}
|
||||
},
|
||||
opts.basePath,
|
||||
)}
|
||||
<div class="chat-group-messages">
|
||||
${group.messages.map((item, index) =>
|
||||
renderGroupedMessage(
|
||||
@@ -150,24 +172,304 @@ export function renderMessageGroup(
|
||||
<div class="chat-group-footer">
|
||||
<span class="chat-sender-name">${who}</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>
|
||||
`;
|
||||
}
|
||||
|
||||
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 assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const initial =
|
||||
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"
|
||||
? 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"
|
||||
? "⚙"
|
||||
: "?";
|
||||
? 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 =
|
||||
normalized === "user"
|
||||
? "user"
|
||||
@@ -185,7 +487,21 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
|
||||
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>`;
|
||||
@@ -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(
|
||||
message: unknown,
|
||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||
@@ -229,6 +618,7 @@ function renderGroupedMessage(
|
||||
) {
|
||||
const m = message as Record<string, unknown>;
|
||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||
const normalizedRole = normalizeRoleForGrouping(role);
|
||||
const isToolResult =
|
||||
isToolResultMessage(message) ||
|
||||
role.toLowerCase() === "toolresult" ||
|
||||
@@ -249,26 +639,49 @@ function renderGroupedMessage(
|
||||
const markdown = markdownBase;
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
|
||||
const bubbleClasses = [
|
||||
"chat-bubble",
|
||||
canCopyMarkdown ? "has-copy" : "",
|
||||
opts.isStreaming ? "streaming" : "",
|
||||
"fade-in",
|
||||
]
|
||||
// Detect pure-JSON messages and render as collapsible block
|
||||
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
|
||||
|
||||
const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
if (!markdown && hasToolCards && isToolResult) {
|
||||
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
|
||||
return renderCollapsedToolCards(toolCards, onOpenSidebar);
|
||||
}
|
||||
|
||||
if (!markdown && !hasToolCards && !hasImages) {
|
||||
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`
|
||||
<div class="${bubbleClasses}">
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
${canCopyMarkdown ? html`<div class="chat-bubble-actions">${renderCopyAsMarkdownButton(markdown!)}</div>` : nothing}
|
||||
${
|
||||
isToolMessage
|
||||
? html`
|
||||
<details class="chat-tool-msg-collapse">
|
||||
<summary class="chat-tool-msg-summary">
|
||||
<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
|
||||
@@ -278,11 +691,47 @@ function renderGroupedMessage(
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
markdown
|
||||
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
|
||||
}
|
||||
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||
${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}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ export class PinnedMessages {
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
ui/src/ui/chat/pinned-summary.ts
Normal file
5
ui/src/ui/chat/pinned-summary.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { extractTextCached } from "./message-extract.ts";
|
||||
|
||||
export function getPinnedMessageSummary(message: unknown): string {
|
||||
return extractTextCached(message) ?? "";
|
||||
}
|
||||
10
ui/src/ui/chat/search-match.ts
Normal file
10
ui/src/ui/chat/search-match.ts
Normal 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);
|
||||
}
|
||||
26
ui/src/ui/chat/session-cache.ts
Normal file
26
ui/src/ui/chat/session-cache.ts
Normal 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;
|
||||
}
|
||||
@@ -4,14 +4,13 @@
|
||||
*/
|
||||
|
||||
import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
|
||||
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
|
||||
import {
|
||||
formatThinkingLevels,
|
||||
normalizeThinkLevel,
|
||||
normalizeVerboseLevel,
|
||||
resolveThinkingDefaultForModel,
|
||||
} from "../../../../src/auto-reply/thinking.js";
|
||||
import type { HealthSummary } from "../../../../src/commands/health.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
DEFAULT_MAIN_KEY,
|
||||
@@ -176,6 +175,7 @@ async function executeThink(
|
||||
args: string,
|
||||
): Promise<SlashCommandResult> {
|
||||
const rawLevel = args.trim();
|
||||
|
||||
if (!rawLevel) {
|
||||
try {
|
||||
const { session, models } = await loadThinkingCommandState(client, sessionKey);
|
||||
@@ -219,6 +219,7 @@ async function executeVerbose(
|
||||
args: string,
|
||||
): Promise<SlashCommandResult> {
|
||||
const rawLevel = args.trim();
|
||||
|
||||
if (!rawLevel) {
|
||||
try {
|
||||
const session = await loadCurrentSession(client, sessionKey);
|
||||
@@ -526,8 +527,7 @@ function resolveCurrentThinkingLevel(
|
||||
if (!session?.modelProvider || !session.model) {
|
||||
return "off";
|
||||
}
|
||||
return resolveThinkingDefault({
|
||||
cfg: {} as OpenClawConfig,
|
||||
return resolveThinkingDefaultForModel({
|
||||
provider: session.modelProvider,
|
||||
model: session.model,
|
||||
catalog: models,
|
||||
|
||||
@@ -21,14 +21,14 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
||||
{
|
||||
name: "new",
|
||||
description: "Start a new session",
|
||||
icon: "circle",
|
||||
icon: "plus",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
description: "Reset current session",
|
||||
icon: "loader",
|
||||
icon: "refresh",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
@@ -42,21 +42,21 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
||||
{
|
||||
name: "stop",
|
||||
description: "Stop current run",
|
||||
icon: "x",
|
||||
icon: "stop",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "clear",
|
||||
description: "Clear chat history",
|
||||
icon: "x",
|
||||
icon: "trash",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
{
|
||||
name: "focus",
|
||||
description: "Toggle focus mode",
|
||||
icon: "search",
|
||||
icon: "eye",
|
||||
category: "session",
|
||||
executeLocal: true,
|
||||
},
|
||||
@@ -77,13 +77,13 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
||||
icon: "brain",
|
||||
category: "model",
|
||||
executeLocal: true,
|
||||
argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"],
|
||||
argOptions: ["off", "low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
description: "Toggle verbose mode",
|
||||
args: "<on|off|full>",
|
||||
icon: "fileCode",
|
||||
icon: "terminal",
|
||||
category: "model",
|
||||
executeLocal: true,
|
||||
argOptions: ["on", "off", "full"],
|
||||
@@ -107,7 +107,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
||||
{
|
||||
name: "export",
|
||||
description: "Export session to Markdown",
|
||||
icon: "arrowDown",
|
||||
icon: "download",
|
||||
category: "tools",
|
||||
executeLocal: true,
|
||||
},
|
||||
@@ -146,7 +146,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
||||
name: "steer",
|
||||
description: "Steer a sub-agent",
|
||||
args: "<id> <msg>",
|
||||
icon: "zap",
|
||||
icon: "send",
|
||||
category: "agents",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AgentsState = {
|
||||
agentsList: AgentsListResult | null;
|
||||
agentsSelectedId: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogLoadingAgentId?: string | null;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: ToolsCatalogResult | null;
|
||||
};
|
||||
@@ -43,29 +44,46 @@ export async function loadAgents(state: AgentsState) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) {
|
||||
if (!state.client || !state.connected) {
|
||||
export async function loadToolsCatalog(state: AgentsState, agentId: string) {
|
||||
const resolvedAgentId = agentId.trim();
|
||||
if (!state.client || !state.connected || !resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.toolsCatalogLoading) {
|
||||
if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogLoading = true;
|
||||
state.toolsCatalogLoadingAgentId = resolvedAgentId;
|
||||
state.toolsCatalogError = null;
|
||||
state.toolsCatalogResult = null;
|
||||
try {
|
||||
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
|
||||
agentId: agentId ?? state.agentsSelectedId ?? undefined,
|
||||
agentId: resolvedAgentId,
|
||||
includePlugins: true,
|
||||
});
|
||||
if (res) {
|
||||
state.toolsCatalogResult = res;
|
||||
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogResult = res;
|
||||
} catch (err) {
|
||||
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||
return;
|
||||
}
|
||||
state.toolsCatalogResult = null;
|
||||
state.toolsCatalogError = String(err);
|
||||
} finally {
|
||||
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||
state.toolsCatalogLoadingAgentId = null;
|
||||
state.toolsCatalogLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAgentsConfig(state: AgentsConfigSaveState) {
|
||||
const selectedBefore = state.agentsSelectedId;
|
||||
|
||||
@@ -184,9 +184,17 @@ export async function runUpdate(state: ConfigState) {
|
||||
state.updateRunning = true;
|
||||
state.lastError = null;
|
||||
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,
|
||||
});
|
||||
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) {
|
||||
state.lastError = String(err);
|
||||
} finally {
|
||||
@@ -255,3 +263,21 @@ export function ensureAgentConfigEntry(state: ConfigState, agentId: string): num
|
||||
updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
62
ui/src/ui/controllers/health.ts
Normal file
62
ui/src/ui/controllers/health.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
ui/src/ui/controllers/models.ts
Normal file
18
ui/src/ui/controllers/models.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,11 @@ const allowedTags = [
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"button",
|
||||
"code",
|
||||
"del",
|
||||
"details",
|
||||
"div",
|
||||
"em",
|
||||
"h1",
|
||||
"h2",
|
||||
@@ -20,7 +23,9 @@ const allowedTags = [
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"span",
|
||||
"strong",
|
||||
"summary",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
@@ -31,7 +36,19 @@ const allowedTags = [
|
||||
"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 = {
|
||||
ALLOWED_TAGS: allowedTags,
|
||||
ALLOWED_ATTR: allowedAttrs,
|
||||
@@ -45,6 +62,7 @@ const MARKDOWN_CACHE_LIMIT = 200;
|
||||
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
||||
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
|
||||
const markdownCache = new Map<string, string>();
|
||||
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
|
||||
|
||||
function getCachedMarkdown(key: string): string | null {
|
||||
const cached = markdownCache.get(key);
|
||||
@@ -83,6 +101,9 @@ function installHooks() {
|
||||
}
|
||||
node.setAttribute("rel", "noreferrer noopener");
|
||||
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";
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
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 · ${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 {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
|
||||
@@ -10,7 +10,16 @@ export const TAB_GROUPS = [
|
||||
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
|
||||
{
|
||||
label: "settings",
|
||||
tabs: ["config", "debug", "logs"],
|
||||
tabs: [
|
||||
"config",
|
||||
"communications",
|
||||
"appearance",
|
||||
"automation",
|
||||
"infrastructure",
|
||||
"aiAgents",
|
||||
"debug",
|
||||
"logs",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -55,19 +64,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
||||
logs: "/logs",
|
||||
};
|
||||
|
||||
const HIDDEN_SETTINGS_TABS = new Set<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]),
|
||||
);
|
||||
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
|
||||
|
||||
export function normalizeBasePath(basePath: string): string {
|
||||
if (!basePath) {
|
||||
|
||||
@@ -19,11 +19,40 @@ export type UiSettings = {
|
||||
chatShowThinking: boolean;
|
||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||
navCollapsed: boolean; // Collapsible sidebar state
|
||||
navWidth: number; // Sidebar width when expanded (200–400px)
|
||||
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
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 {
|
||||
if (typeof window !== "undefined" && window.sessionStorage) {
|
||||
return window.sessionStorage;
|
||||
@@ -91,17 +120,7 @@ function persistSessionToken(gatewayUrl: string, token: string) {
|
||||
}
|
||||
|
||||
export function loadSettings(): UiSettings {
|
||||
const defaultUrl = (() => {
|
||||
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 { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
|
||||
|
||||
const defaults: UiSettings = {
|
||||
gatewayUrl: defaultUrl,
|
||||
@@ -124,21 +143,19 @@ export function loadSettings(): UiSettings {
|
||||
return defaults;
|
||||
}
|
||||
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(
|
||||
(parsed as { theme?: unknown }).theme,
|
||||
(parsed as { themeMode?: unknown }).themeMode,
|
||||
);
|
||||
const settings = {
|
||||
gatewayUrl:
|
||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||
? parsed.gatewayUrl.trim()
|
||||
: defaults.gatewayUrl,
|
||||
gatewayUrl,
|
||||
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
|
||||
token: loadSessionToken(
|
||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||
? parsed.gatewayUrl.trim()
|
||||
: defaults.gatewayUrl,
|
||||
),
|
||||
token: loadSessionToken(gatewayUrl),
|
||||
sessionKey:
|
||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||
? parsed.sessionKey.trim()
|
||||
|
||||
@@ -62,42 +62,13 @@ function resolveMode(mode: ThemeMode): "light" | "dark" {
|
||||
return mode;
|
||||
}
|
||||
|
||||
function normalizeThemeArgs(
|
||||
themeOrMode: ThemeName | ThemeMode,
|
||||
mode: ThemeMode | undefined,
|
||||
): { 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") {
|
||||
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||
const resolvedMode = resolveMode(mode);
|
||||
if (theme === "claw") {
|
||||
return resolvedMode === "light" ? "light" : "dark";
|
||||
}
|
||||
if (normalized.theme === "knot") {
|
||||
if (theme === "knot") {
|
||||
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -411,6 +411,15 @@ export type {
|
||||
SessionUsageTimeSeries,
|
||||
} 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 =
|
||||
| { kind: "at"; at: string }
|
||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||
@@ -425,9 +434,15 @@ export type CronPayload =
|
||||
kind: "agentTurn";
|
||||
message: string;
|
||||
model?: string;
|
||||
fallbacks?: string[];
|
||||
thinking?: string;
|
||||
timeoutSeconds?: number;
|
||||
allowUnsafeExternalContent?: boolean;
|
||||
lightContext?: boolean;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
|
||||
export type CronDelivery = {
|
||||
@@ -459,9 +474,15 @@ export type CronJobState = {
|
||||
nextRunAtMs?: number;
|
||||
runningAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: "ok" | "error" | "skipped";
|
||||
lastRunStatus?: CronRunStatus;
|
||||
lastStatus?: CronRunStatus;
|
||||
lastError?: string;
|
||||
lastErrorReason?: string;
|
||||
lastDurationMs?: number;
|
||||
consecutiveErrors?: number;
|
||||
lastDelivered?: boolean;
|
||||
lastDeliveryStatus?: CronDeliveryStatus;
|
||||
lastDeliveryError?: string;
|
||||
lastFailureAlertAtMs?: number;
|
||||
};
|
||||
|
||||
@@ -482,25 +503,19 @@ export type CronStatus = {
|
||||
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 = {
|
||||
ts: number;
|
||||
jobId: string;
|
||||
jobName?: string;
|
||||
status?: CronRunsStatusValue;
|
||||
action?: "finished";
|
||||
status?: CronRunStatus;
|
||||
durationMs?: number;
|
||||
error?: string;
|
||||
summary?: string;
|
||||
delivered?: boolean;
|
||||
deliveryStatus?: CronDeliveryStatus;
|
||||
deliveryError?: string;
|
||||
delivered?: boolean;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
runAtMs?: number;
|
||||
nextRunAtMs?: number;
|
||||
model?: string;
|
||||
@@ -512,26 +527,25 @@ export type CronRunLogEntry = {
|
||||
cache_read_tokens?: number;
|
||||
cache_write_tokens?: number;
|
||||
};
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
jobName?: string;
|
||||
};
|
||||
|
||||
export type CronJobsListResult = {
|
||||
jobs?: CronJob[];
|
||||
jobs: CronJob[];
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
hasMore?: boolean;
|
||||
offset?: number;
|
||||
nextOffset?: number | null;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export type CronRunsResult = {
|
||||
entries?: CronRunLogEntry[];
|
||||
entries: CronRunLogEntry[];
|
||||
total?: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
hasMore?: boolean;
|
||||
offset?: number;
|
||||
nextOffset?: number | null;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
export type SkillsStatusConfigCheck = {
|
||||
|
||||
@@ -10,6 +10,8 @@ export type ChatQueueItem = {
|
||||
createdAt: number;
|
||||
attachments?: ChatAttachment[];
|
||||
refreshSessions?: boolean;
|
||||
localCommandArgs?: string;
|
||||
localCommandName?: string;
|
||||
};
|
||||
|
||||
export const CRON_CHANNEL_LAST = "last";
|
||||
|
||||
195
ui/src/ui/views/agents-panels-overview.ts
Normal file
195
ui/src/ui/views/agents-panels-overview.ts
Normal 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)}
|
||||
>×</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>
|
||||
`;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import {
|
||||
formatCronPayload,
|
||||
formatCronSchedule,
|
||||
@@ -36,8 +39,8 @@ function renderAgentContextCard(context: AgentContext, subtitle: string) {
|
||||
<div>${context.identityName}</div>
|
||||
</div>
|
||||
<div class="agent-kv">
|
||||
<div class="label">Identity Emoji</div>
|
||||
<div>${context.identityEmoji}</div>
|
||||
<div class="label">Identity Avatar</div>
|
||||
<div>${context.identityAvatar}</div>
|
||||
</div>
|
||||
<div class="agent-kv">
|
||||
<div class="label">Skills Filter</div>
|
||||
@@ -182,7 +185,7 @@ export function renderAgentChannels(params: {
|
||||
const status = summary.total
|
||||
? `${summary.connected}/${summary.total} connected`
|
||||
: "no accounts";
|
||||
const config = summary.configured
|
||||
const configLabel = summary.configured
|
||||
? `${summary.configured} configured`
|
||||
: "not configured";
|
||||
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
|
||||
@@ -199,8 +202,23 @@ export function renderAgentChannels(params: {
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<div>${status}</div>
|
||||
<div>${config}</div>
|
||||
<div>${configLabel}</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.map(
|
||||
@@ -228,6 +246,7 @@ export function renderAgentCron(params: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
onRunNow: (jobId: string) => void;
|
||||
}) {
|
||||
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
|
||||
return html`
|
||||
@@ -297,6 +316,12 @@ export function renderAgentCron(params: {
|
||||
<div class="list-meta">
|
||||
<div class="mono">${formatCronState(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>
|
||||
`,
|
||||
@@ -389,6 +414,21 @@ export function renderAgentFiles(params: {
|
||||
<div class="agent-file-sub mono">${activeEntry.path}</div>
|
||||
</div>
|
||||
<div class="agent-file-actions">
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
title="Preview rendered markdown"
|
||||
@click=${(e: Event) => {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const dialog = btn
|
||||
.closest(".agent-files-editor")
|
||||
?.querySelector("dialog");
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.eye} Preview
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!isDirty}
|
||||
@@ -414,9 +454,10 @@ export function renderAgentFiles(params: {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<label class="field" style="margin-top: 12px;">
|
||||
<label class="field agent-file-field" style="margin-top: 12px;">
|
||||
<span>Content</span>
|
||||
<textarea
|
||||
class="agent-file-textarea"
|
||||
.value=${draft}
|
||||
@input=${(e: Event) =>
|
||||
params.onFileDraftChange(
|
||||
@@ -425,6 +466,30 @@ export function renderAgentFiles(params: {
|
||||
)}
|
||||
></textarea>
|
||||
</label>
|
||||
<dialog
|
||||
class="md-preview-dialog"
|
||||
@click=${(e: Event) => {
|
||||
const dialog = e.currentTarget as HTMLDialogElement;
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="md-preview-dialog__panel">
|
||||
<div class="md-preview-dialog__header">
|
||||
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
|
||||
<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>
|
||||
|
||||
@@ -2,12 +2,14 @@ import { html, nothing } from "lit";
|
||||
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
|
||||
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
|
||||
import {
|
||||
type AgentToolEntry,
|
||||
type AgentToolSection,
|
||||
isAllowedByPolicy,
|
||||
matchesList,
|
||||
PROFILE_OPTIONS,
|
||||
resolveAgentConfig,
|
||||
resolveToolProfileOptions,
|
||||
resolveToolProfile,
|
||||
TOOL_SECTIONS,
|
||||
resolveToolSections,
|
||||
} from "./agents-utils.ts";
|
||||
import type { SkillGroup } from "./skills-grouping.ts";
|
||||
import { groupSkills } from "./skills-grouping.ts";
|
||||
@@ -17,6 +19,28 @@ import {
|
||||
renderSkillStatusChips,
|
||||
} 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: {
|
||||
agentId: string;
|
||||
configForm: Record<string, unknown> | null;
|
||||
@@ -35,6 +59,8 @@ export function renderAgentTools(params: {
|
||||
const agentTools = config.entry?.tools ?? {};
|
||||
const globalTools = config.globalTools ?? {};
|
||||
const profile = agentTools.profile ?? globalTools.profile ?? "full";
|
||||
const profileOptions = resolveToolProfileOptions(params.toolsCatalogResult);
|
||||
const toolSections = resolveToolSections(params.toolsCatalogResult);
|
||||
const profileSource = agentTools.profile
|
||||
? "agent override"
|
||||
: globalTools.profile
|
||||
@@ -43,7 +69,11 @@ export function renderAgentTools(params: {
|
||||
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
|
||||
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
|
||||
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
|
||||
? []
|
||||
: Array.isArray(agentTools.alsoAllow)
|
||||
@@ -53,17 +83,7 @@ export function renderAgentTools(params: {
|
||||
const basePolicy = hasAgentAllow
|
||||
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
|
||||
: (resolveToolProfile(profile) ?? undefined);
|
||||
const sections =
|
||||
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 toolIds = toolSections.flatMap((section) => section.tools.map((tool) => tool.id));
|
||||
|
||||
const resolveAllowed = (toolId: string) => {
|
||||
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
|
||||
@@ -152,15 +172,6 @@ export function renderAgentTools(params: {
|
||||
</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
|
||||
? html`
|
||||
@@ -188,6 +199,22 @@ export function renderAgentTools(params: {
|
||||
`
|
||||
: 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-kv">
|
||||
@@ -235,50 +262,27 @@ export function renderAgentTools(params: {
|
||||
</div>
|
||||
|
||||
<div class="agent-tools-grid" style="margin-top: 20px;">
|
||||
${sections.map(
|
||||
${toolSections.map(
|
||||
(section) =>
|
||||
html`
|
||||
<div class="agent-tools-section">
|
||||
<div class="agent-tools-header">
|
||||
${section.label}
|
||||
${
|
||||
"source" in section && section.source === "plugin"
|
||||
? html`
|
||||
<span class="mono" style="margin-left: 6px">plugin</span>
|
||||
`
|
||||
section.source === "plugin" && section.pluginId
|
||||
? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="agent-tools-list">
|
||||
${section.tools.map((tool) => {
|
||||
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`
|
||||
<div class="agent-tool-row">
|
||||
<div>
|
||||
<div class="agent-tool-title mono">
|
||||
${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-title mono">${tool.label}</div>
|
||||
<div class="agent-tool-sub">${tool.description}</div>
|
||||
${renderToolBadges(section, tool)}
|
||||
</div>
|
||||
<label class="cfg-toggle">
|
||||
<input
|
||||
@@ -298,13 +302,6 @@ export function renderAgentTools(params: {
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
params.toolsCatalogLoading
|
||||
? html`
|
||||
<div class="card-sub" style="margin-top: 10px">Refreshing tool catalog…</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -361,9 +358,10 @@ export function renderAgentSkills(params: {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
||||
<div class="row" style="gap: 4px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 2px;">
|
||||
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
|
||||
Use All
|
||||
Enable All
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
@@ -372,6 +370,15 @@ export function renderAgentSkills(params: {
|
||||
>
|
||||
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}>
|
||||
Reload Config
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,157 @@
|
||||
import { html } from "lit";
|
||||
import {
|
||||
listCoreToolSections,
|
||||
PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS,
|
||||
} from "../../../../src/agents/tool-catalog.js";
|
||||
import {
|
||||
expandToolGroups,
|
||||
normalizeToolName,
|
||||
resolveToolProfilePolicy,
|
||||
} 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 = {
|
||||
allow?: string[];
|
||||
@@ -55,6 +194,30 @@ export function normalizeAgentLabel(agent: {
|
||||
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) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
@@ -106,6 +269,14 @@ export function agentBadgeText(agentId: string, defaultId: string | 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) {
|
||||
if (bytes == null || !Number.isFinite(bytes)) {
|
||||
return "-";
|
||||
@@ -138,7 +309,7 @@ export type AgentContext = {
|
||||
workspace: string;
|
||||
model: string;
|
||||
identityName: string;
|
||||
identityEmoji: string;
|
||||
identityAvatar: string;
|
||||
skillsLabel: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
@@ -164,14 +335,14 @@ export function buildAgentContext(
|
||||
agent.name?.trim() ||
|
||||
config.entry?.name ||
|
||||
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 skillCount = skillFilter?.length ?? null;
|
||||
return {
|
||||
workspace,
|
||||
model: modelLabel,
|
||||
identityName,
|
||||
identityEmoji,
|
||||
identityAvatar,
|
||||
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
|
||||
isDefault: Boolean(defaultId && agent.id === defaultId),
|
||||
};
|
||||
|
||||
@@ -9,64 +9,78 @@ import type {
|
||||
SkillStatusReport,
|
||||
ToolsCatalogResult,
|
||||
} from "../types.ts";
|
||||
import { renderAgentOverview } from "./agents-panels-overview.ts";
|
||||
import {
|
||||
renderAgentFiles,
|
||||
renderAgentChannels,
|
||||
renderAgentCron,
|
||||
} from "./agents-panels-status-files.ts";
|
||||
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
||||
import {
|
||||
agentBadgeText,
|
||||
buildAgentContext,
|
||||
buildModelOptions,
|
||||
normalizeAgentLabel,
|
||||
normalizeModelValue,
|
||||
parseFallbackList,
|
||||
resolveAgentConfig,
|
||||
resolveAgentEmoji,
|
||||
resolveEffectiveModelFallbacks,
|
||||
resolveModelLabel,
|
||||
resolveModelPrimary,
|
||||
} from "./agents-utils.ts";
|
||||
import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
|
||||
|
||||
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 = {
|
||||
basePath: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
agentsList: AgentsListResult | null;
|
||||
selectedAgentId: string | null;
|
||||
activePanel: AgentsPanel;
|
||||
configForm: Record<string, unknown> | null;
|
||||
configLoading: boolean;
|
||||
configSaving: boolean;
|
||||
configDirty: boolean;
|
||||
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;
|
||||
config: ConfigState;
|
||||
channels: ChannelsState;
|
||||
cron: CronState;
|
||||
agentFiles: AgentFilesState;
|
||||
agentIdentityLoading: boolean;
|
||||
agentIdentityError: string | null;
|
||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||
agentSkillsLoading: boolean;
|
||||
agentSkillsReport: SkillStatusReport | null;
|
||||
agentSkillsError: string | null;
|
||||
agentSkillsAgentId: string | null;
|
||||
toolsCatalogLoading: boolean;
|
||||
toolsCatalogError: string | null;
|
||||
toolsCatalogResult: ToolsCatalogResult | null;
|
||||
skillsFilter: string;
|
||||
agentSkills: AgentSkillsState;
|
||||
toolsCatalog: ToolsCatalogState;
|
||||
onRefresh: () => void;
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
onSelectPanel: (panel: AgentsPanel) => void;
|
||||
@@ -83,20 +97,13 @@ export type AgentsProps = {
|
||||
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
||||
onChannelsRefresh: () => void;
|
||||
onCronRefresh: () => void;
|
||||
onCronRunNow: (jobId: string) => void;
|
||||
onSkillsFilterChange: (next: string) => void;
|
||||
onSkillsRefresh: () => void;
|
||||
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
|
||||
onAgentSkillsClear: (agentId: string) => void;
|
||||
onAgentSkillsDisableAll: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export type AgentContext = {
|
||||
workspace: string;
|
||||
model: string;
|
||||
identityName: string;
|
||||
identityEmoji: string;
|
||||
skillsLabel: string;
|
||||
isDefault: boolean;
|
||||
onSetDefault: (agentId: string) => void;
|
||||
};
|
||||
|
||||
export function renderAgents(props: AgentsProps) {
|
||||
@@ -107,49 +114,96 @@ export function renderAgents(props: AgentsProps) {
|
||||
? (agents.find((agent) => agent.id === selectedId) ?? 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`
|
||||
<div class="agents-layout">
|
||||
<section class="card agents-sidebar">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Agents</div>
|
||||
<div class="card-sub">${agents.length} configured.</div>
|
||||
</div>
|
||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="agent-list" style="margin-top: 12px;">
|
||||
<section class="agents-toolbar">
|
||||
<div class="agents-toolbar-row">
|
||||
<span class="agents-toolbar-label">Agent</span>
|
||||
<div class="agents-control-row">
|
||||
<div class="agents-control-select">
|
||||
<select
|
||||
class="agents-select"
|
||||
.value=${selectedId ?? ""}
|
||||
?disabled=${props.loading || agents.length === 0}
|
||||
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${
|
||||
agents.length === 0
|
||||
? html`
|
||||
<div class="muted">No agents found.</div>
|
||||
<option value="">No agents</option>
|
||||
`
|
||||
: agents.map((agent) => {
|
||||
const badge = agentBadgeText(agent.id, defaultId);
|
||||
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
|
||||
return html`
|
||||
: 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"
|
||||
class="agent-row ${selectedId === agent.id ? "active" : ""}"
|
||||
@click=${() => props.onSelectAgent(agent.id)}
|
||||
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
|
||||
@click=${() => {
|
||||
props.onSetDefault(selectedAgent.id);
|
||||
actionsMenuOpen = false;
|
||||
}}
|
||||
>
|
||||
<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}
|
||||
${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>
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
</section>
|
||||
<section class="agents-main">
|
||||
${
|
||||
@@ -161,29 +215,26 @@ export function renderAgents(props: AgentsProps) {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${renderAgentHeader(
|
||||
selectedAgent,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
)}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
|
||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
|
||||
${
|
||||
props.activePanel === "overview"
|
||||
? renderAgentOverview({
|
||||
agent: selectedAgent,
|
||||
basePath: props.basePath,
|
||||
defaultId,
|
||||
configForm: props.configForm,
|
||||
agentFilesList: props.agentFilesList,
|
||||
configForm: props.config.form,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
agentIdentityError: props.agentIdentityError,
|
||||
agentIdentityLoading: props.agentIdentityLoading,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
onConfigReload: props.onConfigReload,
|
||||
onConfigSave: props.onConfigSave,
|
||||
onModelChange: props.onModelChange,
|
||||
onModelFallbacksChange: props.onModelFallbacksChange,
|
||||
onSelectPanel: props.onSelectPanel,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -191,13 +242,13 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "files"
|
||||
? renderAgentFiles({
|
||||
agentId: selectedAgent.id,
|
||||
agentFilesList: props.agentFilesList,
|
||||
agentFilesLoading: props.agentFilesLoading,
|
||||
agentFilesError: props.agentFilesError,
|
||||
agentFileActive: props.agentFileActive,
|
||||
agentFileContents: props.agentFileContents,
|
||||
agentFileDrafts: props.agentFileDrafts,
|
||||
agentFileSaving: props.agentFileSaving,
|
||||
agentFilesList: props.agentFiles.list,
|
||||
agentFilesLoading: props.agentFiles.loading,
|
||||
agentFilesError: props.agentFiles.error,
|
||||
agentFileActive: props.agentFiles.active,
|
||||
agentFileContents: props.agentFiles.contents,
|
||||
agentFileDrafts: props.agentFiles.drafts,
|
||||
agentFileSaving: props.agentFiles.saving,
|
||||
onLoadFiles: props.onLoadFiles,
|
||||
onSelectFile: props.onSelectFile,
|
||||
onFileDraftChange: props.onFileDraftChange,
|
||||
@@ -210,13 +261,13 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "tools"
|
||||
? renderAgentTools({
|
||||
agentId: selectedAgent.id,
|
||||
configForm: props.configForm,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
toolsCatalogLoading: props.toolsCatalogLoading,
|
||||
toolsCatalogError: props.toolsCatalogError,
|
||||
toolsCatalogResult: props.toolsCatalogResult,
|
||||
configForm: props.config.form,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
toolsCatalogLoading: props.toolsCatalog.loading,
|
||||
toolsCatalogError: props.toolsCatalog.error,
|
||||
toolsCatalogResult: props.toolsCatalog.result,
|
||||
onProfileChange: props.onToolsProfileChange,
|
||||
onOverridesChange: props.onToolsOverridesChange,
|
||||
onConfigReload: props.onConfigReload,
|
||||
@@ -228,15 +279,15 @@ export function renderAgents(props: AgentsProps) {
|
||||
props.activePanel === "skills"
|
||||
? renderAgentSkills({
|
||||
agentId: selectedAgent.id,
|
||||
report: props.agentSkillsReport,
|
||||
loading: props.agentSkillsLoading,
|
||||
error: props.agentSkillsError,
|
||||
activeAgentId: props.agentSkillsAgentId,
|
||||
configForm: props.configForm,
|
||||
configLoading: props.configLoading,
|
||||
configSaving: props.configSaving,
|
||||
configDirty: props.configDirty,
|
||||
filter: props.skillsFilter,
|
||||
report: props.agentSkills.report,
|
||||
loading: props.agentSkills.loading,
|
||||
error: props.agentSkills.error,
|
||||
activeAgentId: props.agentSkills.agentId,
|
||||
configForm: props.config.form,
|
||||
configLoading: props.config.loading,
|
||||
configSaving: props.config.saving,
|
||||
configDirty: props.config.dirty,
|
||||
filter: props.agentSkills.filter,
|
||||
onFilterChange: props.onSkillsFilterChange,
|
||||
onRefresh: props.onSkillsRefresh,
|
||||
onToggle: props.onAgentSkillToggle,
|
||||
@@ -252,16 +303,16 @@ export function renderAgents(props: AgentsProps) {
|
||||
? renderAgentChannels({
|
||||
context: buildAgentContext(
|
||||
selectedAgent,
|
||||
props.configForm,
|
||||
props.agentFilesList,
|
||||
props.config.form,
|
||||
props.agentFiles.list,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
),
|
||||
configForm: props.configForm,
|
||||
snapshot: props.channelsSnapshot,
|
||||
loading: props.channelsLoading,
|
||||
error: props.channelsError,
|
||||
lastSuccess: props.channelsLastSuccess,
|
||||
configForm: props.config.form,
|
||||
snapshot: props.channels.snapshot,
|
||||
loading: props.channels.loading,
|
||||
error: props.channels.error,
|
||||
lastSuccess: props.channels.lastSuccess,
|
||||
onRefresh: props.onChannelsRefresh,
|
||||
})
|
||||
: nothing
|
||||
@@ -271,17 +322,18 @@ export function renderAgents(props: AgentsProps) {
|
||||
? renderAgentCron({
|
||||
context: buildAgentContext(
|
||||
selectedAgent,
|
||||
props.configForm,
|
||||
props.agentFilesList,
|
||||
props.config.form,
|
||||
props.agentFiles.list,
|
||||
defaultId,
|
||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||
),
|
||||
agentId: selectedAgent.id,
|
||||
jobs: props.cronJobs,
|
||||
status: props.cronStatus,
|
||||
loading: props.cronLoading,
|
||||
error: props.cronError,
|
||||
jobs: props.cron.jobs,
|
||||
status: props.cron.status,
|
||||
loading: props.cron.loading,
|
||||
error: props.cron.error,
|
||||
onRefresh: props.onCronRefresh,
|
||||
onRunNow: props.onCronRunNow,
|
||||
})
|
||||
: nothing
|
||||
}
|
||||
@@ -292,33 +344,13 @@ export function renderAgents(props: AgentsProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAgentHeader(
|
||||
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>
|
||||
`;
|
||||
}
|
||||
let actionsMenuOpen = false;
|
||||
|
||||
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 }> = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "files", label: "Files" },
|
||||
@@ -336,164 +368,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) =>
|
||||
type="button"
|
||||
@click=${() => onSelect(tab.id)}
|
||||
>
|
||||
${tab.label}
|
||||
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
33
ui/src/ui/views/bottom-tabs.ts
Normal file
33
ui/src/ui/views/bottom-tabs.ts
Normal 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
263
ui/src/ui/views/command-palette.ts
Normal file
263
ui/src/ui/views/command-palette.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -249,11 +249,21 @@ function normalizeUnion(
|
||||
return res;
|
||||
}
|
||||
|
||||
const primitiveTypes = new Set(["string", "number", "integer", "boolean"]);
|
||||
const renderableUnionTypes = new Set([
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"object",
|
||||
"array",
|
||||
]);
|
||||
if (
|
||||
remaining.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 {
|
||||
schema: {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { icons as sharedIcons } from "../icons.ts";
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import {
|
||||
defaultValue,
|
||||
hasSensitiveConfigData,
|
||||
hintForPath,
|
||||
humanize,
|
||||
pathKey,
|
||||
REDACTED_PLACEHOLDER,
|
||||
schemaType,
|
||||
type JsonSchema,
|
||||
} from "./config-form.shared.ts";
|
||||
@@ -100,11 +103,77 @@ type FieldMeta = {
|
||||
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 = {
|
||||
text: 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 {
|
||||
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
|
||||
}
|
||||
@@ -331,6 +400,9 @@ export function renderNode(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult | typeof nothing {
|
||||
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
|
||||
@@ -537,6 +623,9 @@ function renderTextInput(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
inputType: "text" | "number";
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
@@ -544,17 +633,22 @@ function renderTextInput(params: {
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const hint = hintForPath(path, hints);
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const isSensitive =
|
||||
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
|
||||
const placeholder =
|
||||
hint?.placeholder ??
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path,
|
||||
value,
|
||||
hints,
|
||||
revealSensitive: params.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||
});
|
||||
const placeholder = sensitiveState.isRedacted
|
||||
? REDACTED_PLACEHOLDER
|
||||
: (hint?.placeholder ??
|
||||
// oxlint-disable typescript/no-base-to-string
|
||||
(isSensitive
|
||||
? "••••"
|
||||
: schema.default !== undefined
|
||||
? `Default: ${String(schema.default)}`
|
||||
: "");
|
||||
const displayValue = value ?? "";
|
||||
(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`
|
||||
<div class="cfg-field">
|
||||
@@ -563,12 +657,16 @@ function renderTextInput(params: {
|
||||
${renderTags(tags)}
|
||||
<div class="cfg-input-wrap">
|
||||
<input
|
||||
type=${isSensitive ? "password" : inputType}
|
||||
type=${effectiveInputType}
|
||||
class="cfg-input"
|
||||
placeholder=${placeholder}
|
||||
.value=${displayValue == null ? "" : String(displayValue)}
|
||||
?disabled=${disabled}
|
||||
?disabled=${effectiveDisabled}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@input=${(e: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
if (inputType === "number") {
|
||||
if (raw.trim() === "") {
|
||||
@@ -582,13 +680,19 @@ function renderTextInput(params: {
|
||||
onPatch(path, raw);
|
||||
}}
|
||||
@change=${(e: Event) => {
|
||||
if (inputType === "number") {
|
||||
if (inputType === "number" || sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const raw = (e.target as HTMLInputElement).value;
|
||||
onPatch(path, raw.trim());
|
||||
}}
|
||||
/>
|
||||
${renderSensitiveToggleButton({
|
||||
path,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||
})}
|
||||
${
|
||||
schema.default !== undefined
|
||||
? html`
|
||||
@@ -596,7 +700,7 @@ function renderTextInput(params: {
|
||||
type="button"
|
||||
class="cfg-input__reset"
|
||||
title="Reset to default"
|
||||
?disabled=${disabled}
|
||||
?disabled=${effectiveDisabled}
|
||||
@click=${() => onPatch(path, schema.default)}
|
||||
>↺</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: {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
@@ -711,9 +882,24 @@ function renderObject(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
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, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
@@ -754,6 +940,9 @@ function renderObject(params: {
|
||||
unsupported,
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
}),
|
||||
)}
|
||||
@@ -768,6 +957,9 @@ function renderObject(params: {
|
||||
disabled,
|
||||
reservedKeys: reserved,
|
||||
searchCriteria: childSearchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})
|
||||
: nothing
|
||||
@@ -818,9 +1010,24 @@ function renderArray(params: {
|
||||
disabled: boolean;
|
||||
showLabel?: boolean;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
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, unsupported, disabled, onPatch, searchCriteria } = params;
|
||||
const {
|
||||
schema,
|
||||
value,
|
||||
path,
|
||||
hints,
|
||||
unsupported,
|
||||
disabled,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const showLabel = params.showLabel ?? true;
|
||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||
const selfMatched =
|
||||
@@ -900,6 +1107,9 @@ function renderArray(params: {
|
||||
disabled,
|
||||
searchCriteria: childSearchCriteria,
|
||||
showLabel: false,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})}
|
||||
</div>
|
||||
@@ -922,6 +1132,9 @@ function renderMapField(params: {
|
||||
disabled: boolean;
|
||||
reservedKeys: Set<string>;
|
||||
searchCriteria?: ConfigSearchCriteria;
|
||||
revealSensitive?: boolean;
|
||||
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||
}): TemplateResult {
|
||||
const {
|
||||
@@ -934,6 +1147,9 @@ function renderMapField(params: {
|
||||
reservedKeys,
|
||||
onPatch,
|
||||
searchCriteria,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
} = params;
|
||||
const anySchema = isAnySchema(schema);
|
||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||
@@ -985,6 +1201,13 @@ function renderMapField(params: {
|
||||
${visibleEntries.map(([key, entryValue]) => {
|
||||
const valuePath = [...path, key];
|
||||
const fallback = jsonValue(entryValue);
|
||||
const sensitiveState = getSensitiveRenderState({
|
||||
path: valuePath,
|
||||
value: entryValue,
|
||||
hints,
|
||||
revealSensitive: revealSensitive ?? false,
|
||||
isSensitivePathRevealed,
|
||||
});
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-header">
|
||||
@@ -1028,13 +1251,20 @@ function renderMapField(params: {
|
||||
${
|
||||
anySchema
|
||||
? html`
|
||||
<div class="cfg-input-wrap">
|
||||
<textarea
|
||||
class="cfg-textarea cfg-textarea--sm"
|
||||
placeholder="JSON value"
|
||||
placeholder=${
|
||||
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
|
||||
}
|
||||
rows="2"
|
||||
.value=${fallback}
|
||||
?disabled=${disabled}
|
||||
.value=${sensitiveState.isRedacted ? "" : fallback}
|
||||
?disabled=${disabled || sensitiveState.isRedacted}
|
||||
?readonly=${sensitiveState.isRedacted}
|
||||
@change=${(e: Event) => {
|
||||
if (sensitiveState.isRedacted) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
@@ -1048,6 +1278,13 @@ function renderMapField(params: {
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
${renderSensitiveToggleButton({
|
||||
path: valuePath,
|
||||
state: sensitiveState,
|
||||
disabled,
|
||||
onToggleSensitivePath,
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: renderNode({
|
||||
schema,
|
||||
@@ -1058,6 +1295,9 @@ function renderMapField(params: {
|
||||
disabled,
|
||||
searchCriteria,
|
||||
showLabel: false,
|
||||
revealSensitive,
|
||||
isSensitivePathRevealed,
|
||||
onToggleSensitivePath,
|
||||
onPatch,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export type ConfigFormProps = {
|
||||
searchQuery?: string;
|
||||
activeSection?: 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;
|
||||
};
|
||||
|
||||
@@ -431,6 +434,9 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
revealSensitive: props.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
@@ -466,6 +472,9 @@ export function renderConfigForm(props: ConfigFormProps) {
|
||||
disabled: props.disabled ?? false,
|
||||
showLabel: false,
|
||||
searchCriteria,
|
||||
revealSensitive: props.revealSensitive ?? false,
|
||||
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||
onPatch: props.onPatch,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConfigUiHints } from "../types.ts";
|
||||
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
|
||||
|
||||
export type JsonSchema = {
|
||||
type?: string | string[];
|
||||
@@ -94,3 +94,110 @@ export function humanize(raw: string) {
|
||||
.replace(/\s+/g, " ")
|
||||
.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
@@ -360,7 +360,9 @@ export function renderCron(props: CronProps) {
|
||||
props.runsScope === "all"
|
||||
? t("cron.jobList.allJobs")
|
||||
: (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 runDeliveryOptions = getRunDeliveryOptions();
|
||||
const selectedStatusLabels = runStatusOptions
|
||||
@@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) {
|
||||
?disabled=${props.busy}
|
||||
@click=${(event: Event) => {
|
||||
event.stopPropagation();
|
||||
selectAnd(() => props.onLoadRuns(job.id));
|
||||
props.onLoadRuns(job.id);
|
||||
}}
|
||||
>
|
||||
${t("cron.jobList.history")}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function renderDebug(props: DebugProps) {
|
||||
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
export type InstancesProps = {
|
||||
@@ -10,7 +11,11 @@ export type InstancesProps = {
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
let hostsRevealed = false;
|
||||
|
||||
export function renderInstances(props: InstancesProps) {
|
||||
const masked = !hostsRevealed;
|
||||
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
@@ -18,10 +23,25 @@ export function renderInstances(props: InstancesProps) {
|
||||
<div class="card-title">Connected Instances</div>
|
||||
<div class="card-sub">Presence beacons from the gateway and clients.</div>
|
||||
</div>
|
||||
<div class="row" style="gap: 8px;">
|
||||
<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>
|
||||
${
|
||||
props.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||
@@ -42,16 +62,18 @@ export function renderInstances(props: InstancesProps) {
|
||||
? html`
|
||||
<div class="muted">No instances reported yet.</div>
|
||||
`
|
||||
: props.entries.map((entry) => renderEntry(entry))
|
||||
: props.entries.map((entry) => renderEntry(entry, masked))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEntry(entry: PresenceEntry) {
|
||||
function renderEntry(entry: PresenceEntry, masked: boolean) {
|
||||
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
|
||||
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 scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
|
||||
const scopesLabel =
|
||||
@@ -63,8 +85,12 @@ function renderEntry(entry: PresenceEntry) {
|
||||
return html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="list-title">${entry.host ?? "unknown host"}</div>
|
||||
<div class="list-sub">${formatPresenceSummary(entry)}</div>
|
||||
<div class="list-title">
|
||||
<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">
|
||||
<span class="chip">${mode}</span>
|
||||
${roles.map((role) => html`<span class="chip">${role}</span>`)}
|
||||
|
||||
132
ui/src/ui/views/login-gate.ts
Normal file
132
ui/src/ui/views/login-gate.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
61
ui/src/ui/views/overview-attention.ts
Normal file
61
ui/src/ui/views/overview-attention.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
162
ui/src/ui/views/overview-cards.ts
Normal file
162
ui/src/ui/views/overview-cards.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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
|
||||
}
|
||||
`;
|
||||
}
|
||||
42
ui/src/ui/views/overview-event-log.ts
Normal file
42
ui/src/ui/views/overview-event-log.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -1,5 +1,31 @@
|
||||
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. */
|
||||
export function shouldShowPairingHint(
|
||||
connected: boolean,
|
||||
@@ -14,3 +40,44 @@ export function shouldShowPairingHint(
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
44
ui/src/ui/views/overview-log-tail.ts
Normal file
44
ui/src/ui/views/overview-log-tail.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
31
ui/src/ui/views/overview-quick-actions.ts
Normal file
31
ui/src/ui/views/overview-quick-actions.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
import { html } from "lit";
|
||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||
import { html, nothing } from "lit";
|
||||
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 { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||
import type { GatewayHelloOk } from "../gateway.ts";
|
||||
import { formatNextRun } from "../presenter.ts";
|
||||
import { icons } from "../icons.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 = {
|
||||
connected: boolean;
|
||||
@@ -20,24 +37,39 @@ export type OverviewProps = {
|
||||
cronEnabled: boolean | null;
|
||||
cronNext: 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;
|
||||
onPasswordChange: (next: string) => void;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
onToggleGatewayTokenVisibility: () => void;
|
||||
onToggleGatewayPasswordVisibility: () => void;
|
||||
onConnect: () => void;
|
||||
onRefresh: () => void;
|
||||
onNavigate: (tab: string) => void;
|
||||
onRefreshLogs: () => void;
|
||||
};
|
||||
|
||||
export function renderOverview(props: OverviewProps) {
|
||||
const snapshot = props.hello?.snapshot as
|
||||
| {
|
||||
uptimeMs?: number;
|
||||
policy?: { tickIntervalMs?: number };
|
||||
authMode?: "none" | "token" | "password" | "trusted-proxy";
|
||||
}
|
||||
| undefined;
|
||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
|
||||
const tick = snapshot?.policy?.tickIntervalMs
|
||||
? `${snapshot.policy.tickIntervalMs}ms`
|
||||
const tickIntervalMs = props.hello?.policy?.tickIntervalMs;
|
||||
const tick = tickIntervalMs
|
||||
? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
|
||||
: t("common.na");
|
||||
const authMode = snapshot?.authMode;
|
||||
const isTrustedProxy = authMode === "trusted-proxy";
|
||||
@@ -74,38 +106,12 @@ export function renderOverview(props: OverviewProps) {
|
||||
if (props.connected || !props.lastError) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
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) {
|
||||
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
|
||||
return null;
|
||||
}
|
||||
const hasToken = Boolean(props.settings.token.trim());
|
||||
const hasPassword = Boolean(props.password.trim());
|
||||
const isAuthRequired = props.lastErrorCode
|
||||
? authRequiredCodes.has(props.lastErrorCode)
|
||||
: !hasToken && !hasPassword;
|
||||
if (isAuthRequired) {
|
||||
if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px">
|
||||
${t("overview.auth.required")}
|
||||
@@ -151,15 +157,7 @@ export function renderOverview(props: OverviewProps) {
|
||||
if (isSecureContext) {
|
||||
return null;
|
||||
}
|
||||
const lower = props.lastError.toLowerCase();
|
||||
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")
|
||||
) {
|
||||
if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
@@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) {
|
||||
const currentLocale = i18n.getLocale();
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<div class="card-title">${t("overview.access.title")}</div>
|
||||
<div class="card-sub">${t("overview.access.subtitle")}</div>
|
||||
<div class="form-grid" style="margin-top: 16px;">
|
||||
<label class="field">
|
||||
<div class="ov-access-grid" style="margin-top: 16px;">
|
||||
<label class="field ov-access-grid__full">
|
||||
<span>${t("overview.access.wsUrl")}</span>
|
||||
<input
|
||||
.value=${props.settings.gatewayUrl}
|
||||
@@ -220,7 +218,11 @@ export function renderOverview(props: OverviewProps) {
|
||||
: html`
|
||||
<label class="field">
|
||||
<span>${t("overview.access.token")}</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input
|
||||
type=${props.showGatewayToken ? "text" : "password"}
|
||||
autocomplete="off"
|
||||
style="flex: 1;"
|
||||
.value=${props.settings.token}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
@@ -228,11 +230,26 @@ export function renderOverview(props: OverviewProps) {
|
||||
}}
|
||||
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 class="field">
|
||||
<span>${t("overview.access.password")}</span>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input
|
||||
type="password"
|
||||
type=${props.showGatewayPassword ? "text" : "password"}
|
||||
autocomplete="off"
|
||||
style="flex: 1;"
|
||||
.value=${props.password}
|
||||
@input=${(e: Event) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
@@ -240,6 +257,18 @@ export function renderOverview(props: OverviewProps) {
|
||||
}}
|
||||
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>
|
||||
`
|
||||
}
|
||||
@@ -277,6 +306,30 @@ export function renderOverview(props: OverviewProps) {
|
||||
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
|
||||
}</span>
|
||||
</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 class="card">
|
||||
@@ -321,45 +374,32 @@ export function renderOverview(props: OverviewProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-3" style="margin-top: 18px;">
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.instances")}</div>
|
||||
<div class="stat-value">${props.presenceCount}</div>
|
||||
<div class="muted">${t("overview.stats.instancesHint")}</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.sessions")}</div>
|
||||
<div class="stat-value">${props.sessionsCount ?? t("common.na")}</div>
|
||||
<div class="muted">${t("overview.stats.sessionsHint")}</div>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<div class="stat-label">${t("overview.stats.cron")}</div>
|
||||
<div class="stat-value">
|
||||
${props.cronEnabled == null ? t("common.na") : props.cronEnabled ? t("common.enabled") : t("common.disabled")}
|
||||
</div>
|
||||
<div class="muted">${t("overview.stats.cronNext", { time: formatNextRun(props.cronNext) })}</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="ov-section-divider"></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")}
|
||||
${renderOverviewCards({
|
||||
usageResult: props.usageResult,
|
||||
sessionsResult: props.sessionsResult,
|
||||
skillsReport: props.skillsReport,
|
||||
cronJobs: props.cronJobs,
|
||||
cronStatus: props.cronStatus,
|
||||
presenceCount: props.presenceCount,
|
||||
onNavigate: props.onNavigate,
|
||||
})}
|
||||
|
||||
${renderOverviewAttention({ items: props.attentionItems })}
|
||||
|
||||
<div class="ov-section-divider"></div>
|
||||
|
||||
<div class="ov-bottom-grid" style="margin-top: 18px;">
|
||||
${renderOverviewEventLog({
|
||||
events: props.eventLog,
|
||||
})}
|
||||
|
||||
${renderOverviewLogTail({
|
||||
lines: props.overviewLogLines,
|
||||
onRefreshLogs: props.onRefreshLogs,
|
||||
})}
|
||||
</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>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { formatRelativeTimestamp } from "../format.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { pathForTab } from "../navigation.ts";
|
||||
import { formatSessionTokens } from "../presenter.ts";
|
||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||
@@ -13,12 +14,23 @@ export type SessionsProps = {
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
basePath: string;
|
||||
searchQuery: string;
|
||||
sortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
sortDir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
actionsOpenKey: string | null;
|
||||
onFiltersChange: (next: {
|
||||
activeMinutes: string;
|
||||
limit: string;
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
}) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onActionsOpenChange: (key: string | null) => void;
|
||||
onRefresh: () => void;
|
||||
onPatch: (
|
||||
key: string,
|
||||
@@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [
|
||||
{ value: "full", label: "full" },
|
||||
] as const;
|
||||
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||
|
||||
function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) {
|
||||
@@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
|
||||
return value;
|
||||
}
|
||||
|
||||
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) => {
|
||||
const key = (row.key ?? "").toLowerCase();
|
||||
const label = (row.label ?? "").toLowerCase();
|
||||
const kind = (row.kind ?? "").toLowerCase();
|
||||
const displayName = (row.displayName ?? "").toLowerCase();
|
||||
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(
|
||||
rows: GatewaySessionRow[],
|
||||
column: "key" | "kind" | "updated" | "tokens",
|
||||
dir: "asc" | "desc",
|
||||
): GatewaySessionRow[] {
|
||||
const cmp = dir === "asc" ? 1 : -1;
|
||||
return [...rows].toSorted((a, b) => {
|
||||
let diff = 0;
|
||||
switch (column) {
|
||||
case "key":
|
||||
diff = (a.key ?? "").localeCompare(b.key ?? "");
|
||||
break;
|
||||
case "kind":
|
||||
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
|
||||
break;
|
||||
case "updated": {
|
||||
const au = a.updatedAt ?? 0;
|
||||
const bu = b.updatedAt ?? 0;
|
||||
diff = au - bu;
|
||||
break;
|
||||
}
|
||||
case "tokens": {
|
||||
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
|
||||
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
|
||||
diff = at - bt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return diff * cmp;
|
||||
});
|
||||
}
|
||||
|
||||
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
|
||||
const start = page * pageSize;
|
||||
return rows.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
export function renderSessions(props: SessionsProps) {
|
||||
const rows = props.result?.sessions ?? [];
|
||||
const rawRows = props.result?.sessions ?? [];
|
||||
const filtered = filterRows(rawRows, props.searchQuery);
|
||||
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
|
||||
const totalRows = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
|
||||
const page = Math.min(props.page, totalPages - 1);
|
||||
const paginated = paginateRows(sorted, page, props.pageSize);
|
||||
|
||||
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
|
||||
const isActive = props.sortColumn === col;
|
||||
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
|
||||
return html`
|
||||
<section class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<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`
|
||||
${
|
||||
props.actionsOpenKey
|
||||
? html`
|
||||
<div
|
||||
class="data-table-overlay"
|
||||
@click=${() => props.onActionsOpenChange(null)}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<section class="card" style=${props.actionsOpenKey ? "position: relative; z-index: 41;" : ""}>
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="card-title">Sessions</div>
|
||||
<div class="card-sub">Active session keys and per-session overrides.</div>
|
||||
<div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
|
||||
</div>
|
||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||
${props.loading ? "Loading…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field">
|
||||
<span>Active within (minutes)</span>
|
||||
<div class="filters" style="margin-bottom: 12px;">
|
||||
<label class="field-inline">
|
||||
<span>Active</span>
|
||||
<input
|
||||
style="width: 72px;"
|
||||
placeholder="min"
|
||||
.value=${props.activeMinutes}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -135,9 +234,10 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<label class="field-inline">
|
||||
<span>Limit</span>
|
||||
<input
|
||||
style="width: 64px;"
|
||||
.value=${props.limit}
|
||||
@input=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
@@ -148,8 +248,7 @@ export function renderSessions(props: SessionsProps) {
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include global</span>
|
||||
<label class="field-inline checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeGlobal}
|
||||
@@ -161,9 +260,9 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: props.includeUnknown,
|
||||
})}
|
||||
/>
|
||||
<span>Global</span>
|
||||
</label>
|
||||
<label class="field checkbox">
|
||||
<span>Include unknown</span>
|
||||
<label class="field-inline checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeUnknown}
|
||||
@@ -175,40 +274,103 @@ export function renderSessions(props: SessionsProps) {
|
||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
<span>Unknown</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${
|
||||
props.error
|
||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
||||
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="muted" style="margin-top: 12px;">
|
||||
${props.result ? `Store: ${props.result.path}` : ""}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-toolbar">
|
||||
<div class="data-table-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by key, label, kind…"
|
||||
.value=${props.searchQuery}
|
||||
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table" style="margin-top: 16px;">
|
||||
<div class="table-head">
|
||||
<div>Key</div>
|
||||
<div>Label</div>
|
||||
<div>Kind</div>
|
||||
<div>Updated</div>
|
||||
<div>Tokens</div>
|
||||
<div>Thinking</div>
|
||||
<div>Verbose</div>
|
||||
<div>Reasoning</div>
|
||||
<div>Actions</div>
|
||||
</div>
|
||||
<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>
|
||||
${
|
||||
rows.length === 0
|
||||
paginated.length === 0
|
||||
? html`
|
||||
<div class="muted">No sessions found.</div>
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; padding: 48px 16px; color: var(--muted)">
|
||||
No sessions found.
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
: rows.map((row) =>
|
||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
||||
: paginated.map((row) =>
|
||||
renderRow(
|
||||
row,
|
||||
props.basePath,
|
||||
props.onPatch,
|
||||
props.onDelete,
|
||||
props.onActionsOpenChange,
|
||||
props.actionsOpenKey,
|
||||
props.loading,
|
||||
),
|
||||
)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${
|
||||
totalRows > 0
|
||||
? html`
|
||||
<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>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
@@ -219,6 +381,8 @@ function renderRow(
|
||||
basePath: string,
|
||||
onPatch: SessionsProps["onPatch"],
|
||||
onDelete: SessionsProps["onDelete"],
|
||||
onActionsOpenChange: (key: string | null) => void,
|
||||
actionsOpenKey: string | null,
|
||||
disabled: boolean,
|
||||
) {
|
||||
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||
@@ -234,36 +398,58 @@ function renderRow(
|
||||
typeof row.displayName === "string" && row.displayName.trim().length > 0
|
||||
? row.displayName.trim()
|
||||
: null;
|
||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
||||
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label);
|
||||
const showDisplayName = Boolean(
|
||||
displayName &&
|
||||
displayName !== row.key &&
|
||||
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
|
||||
);
|
||||
const canLink = row.kind !== "global";
|
||||
const chatUrl = canLink
|
||||
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
||||
: null;
|
||||
const isMenuOpen = actionsOpenKey === row.key;
|
||||
const badgeClass =
|
||||
row.kind === "direct"
|
||||
? "data-table-badge--direct"
|
||||
: row.kind === "group"
|
||||
? "data-table-badge--group"
|
||||
: row.kind === "global"
|
||||
? "data-table-badge--global"
|
||||
: "data-table-badge--unknown";
|
||||
|
||||
return html`
|
||||
<div class="table-row">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="mono session-key-cell">
|
||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
||||
${showDisplayName ? html`<span class="muted session-key-display-name">${displayName}</span>` : nothing}
|
||||
${
|
||||
showDisplayName
|
||||
? html`<span class="muted session-key-display-name">${displayName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
.value=${row.label ?? ""}
|
||||
?disabled=${disabled}
|
||||
placeholder="(optional)"
|
||||
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
onPatch(row.key, { label: value || null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>${row.kind}</div>
|
||||
<div>${updated}</div>
|
||||
<div>${formatSessionTokens(row)}</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
|
||||
</td>
|
||||
<td>${updated}</td>
|
||||
<td>${formatSessionTokens(row)}</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, {
|
||||
@@ -278,10 +464,11 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { verboseLevel: value || null });
|
||||
@@ -294,10 +481,11 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
?disabled=${disabled}
|
||||
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||
@change=${(e: Event) => {
|
||||
const value = (e.target as HTMLSelectElement).value;
|
||||
onPatch(row.key, { reasoningLevel: value || null });
|
||||
@@ -310,12 +498,53 @@ function renderRow(
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
||||
</td>
|
||||
<td>
|
||||
<div class="data-table-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="data-table-row-actions__trigger"
|
||||
aria-label="Open menu"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
onActionsOpenChange(isMenuOpen ? null : row.key);
|
||||
}}
|
||||
>
|
||||
${icons.moreHorizontal}
|
||||
</button>
|
||||
${
|
||||
isMenuOpen
|
||||
? html`
|
||||
<div class="data-table-row-actions__menu">
|
||||
${
|
||||
canLink
|
||||
? html`
|
||||
<a
|
||||
href=${chatUrl}
|
||||
style="display: block; padding: 8px 12px; font-size: 13px; text-decoration: none; color: var(--text); border-radius: var(--radius-sm);"
|
||||
@click=${() => onActionsOpenChange(null)}
|
||||
>
|
||||
Open in Chat
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
@click=${() => {
|
||||
onActionsOpenChange(null);
|
||||
onDelete(row.key);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./skills-shared.ts";
|
||||
|
||||
export type SkillsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
report: SkillStatusReport | null;
|
||||
error: string | null;
|
||||
@@ -40,16 +41,22 @@ export function renderSkills(props: SkillsProps) {
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div>
|
||||
<div class="card-title">Skills</div>
|
||||
<div class="card-sub">Bundled, managed, and workspace skills.</div>
|
||||
<div class="card-sub">Installed skills and their status.</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"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filters" style="margin-top: 14px;">
|
||||
<label class="field" style="flex: 1;">
|
||||
<span>Filter</span>
|
||||
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
|
||||
<a
|
||||
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
|
||||
.value=${props.filter}
|
||||
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
||||
@@ -68,7 +75,13 @@ export function renderSkills(props: SkillsProps) {
|
||||
${
|
||||
filtered.length === 0
|
||||
? 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`
|
||||
<div class="agent-skills-groups" style="margin-top: 16px;">
|
||||
|
||||
@@ -39,5 +39,23 @@ export default defineConfig(() => {
|
||||
port: 5173,
|
||||
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: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user