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
|
# Synthing
|
||||||
**/.stfolder/
|
**/.stfolder/
|
||||||
.dev-state
|
.dev-state
|
||||||
|
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
|
||||||
|
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
||||||
|
.gitignore
|
||||||
|
test/config-form.analyze.telegram.test.ts
|
||||||
|
ui/src/ui/theme-variants.browser.test.ts
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentModelFallbackValues,
|
resolveAgentModelFallbackValues,
|
||||||
@@ -36,7 +37,6 @@ const ANTHROPIC_MODEL_ALIASES: Record<string, string> = {
|
|||||||
"sonnet-4.6": "claude-sonnet-4-6",
|
"sonnet-4.6": "claude-sonnet-4-6",
|
||||||
"sonnet-4.5": "claude-sonnet-4-5",
|
"sonnet-4.5": "claude-sonnet-4-5",
|
||||||
};
|
};
|
||||||
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
|
||||||
|
|
||||||
function normalizeAliasKey(value: string): string {
|
function normalizeAliasKey(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
@@ -629,8 +629,8 @@ export function resolveThinkingDefault(params: {
|
|||||||
model: string;
|
model: string;
|
||||||
catalog?: ModelCatalogEntry[];
|
catalog?: ModelCatalogEntry[];
|
||||||
}): ThinkLevel {
|
}): ThinkLevel {
|
||||||
const normalizedProvider = normalizeProviderId(params.provider);
|
const _normalizedProvider = normalizeProviderId(params.provider);
|
||||||
const modelLower = params.model.toLowerCase();
|
const _modelLower = params.model.toLowerCase();
|
||||||
const configuredModels = params.cfg.agents?.defaults?.models;
|
const configuredModels = params.cfg.agents?.defaults?.models;
|
||||||
const canonicalKey = modelKey(params.provider, params.model);
|
const canonicalKey = modelKey(params.provider, params.model);
|
||||||
const legacyKey = legacyModelKey(params.provider, params.model);
|
const legacyKey = legacyModelKey(params.provider, params.model);
|
||||||
@@ -652,21 +652,11 @@ export function resolveThinkingDefault(params: {
|
|||||||
if (configured) {
|
if (configured) {
|
||||||
return configured;
|
return configured;
|
||||||
}
|
}
|
||||||
const isAnthropicFamilyModel =
|
return resolveThinkingDefaultForModel({
|
||||||
normalizedProvider === "anthropic" ||
|
provider: params.provider,
|
||||||
normalizedProvider === "amazon-bedrock" ||
|
model: params.model,
|
||||||
modelLower.includes("anthropic/") ||
|
catalog: params.catalog,
|
||||||
modelLower.includes(".anthropic.");
|
});
|
||||||
if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) {
|
|
||||||
return "adaptive";
|
|
||||||
}
|
|
||||||
const candidate = params.catalog?.find(
|
|
||||||
(entry) => entry.provider === params.provider && entry.id === params.model,
|
|
||||||
);
|
|
||||||
if (candidate?.reasoning) {
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
return "off";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
|
/** Default reasoning level when session/directive do not set it: "on" if model supports reasoning, else "off". */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
listThinkingLevels,
|
listThinkingLevels,
|
||||||
normalizeReasoningLevel,
|
normalizeReasoningLevel,
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
|
resolveThinkingDefaultForModel,
|
||||||
} from "./thinking.js";
|
} from "./thinking.js";
|
||||||
|
|
||||||
describe("normalizeThinkLevel", () => {
|
describe("normalizeThinkLevel", () => {
|
||||||
@@ -84,6 +85,40 @@ describe("listThinkingLevelLabels", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveThinkingDefaultForModel", () => {
|
||||||
|
it("defaults Claude 4.6 models to adaptive", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingDefaultForModel({ provider: "anthropic", model: "claude-opus-4-6" }),
|
||||||
|
).toBe("adaptive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats Bedrock Anthropic aliases as adaptive", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingDefaultForModel({ provider: "aws-bedrock", model: "claude-sonnet-4-6" }),
|
||||||
|
).toBe("adaptive");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults reasoning-capable catalog models to low", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingDefaultForModel({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-5.4",
|
||||||
|
catalog: [{ provider: "openai", id: "gpt-5.4", reasoning: true }],
|
||||||
|
}),
|
||||||
|
).toBe("low");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to off when no adaptive or reasoning hint is present", () => {
|
||||||
|
expect(
|
||||||
|
resolveThinkingDefaultForModel({
|
||||||
|
provider: "openai",
|
||||||
|
model: "gpt-4.1-mini",
|
||||||
|
catalog: [{ provider: "openai", id: "gpt-4.1-mini", reasoning: false }],
|
||||||
|
}),
|
||||||
|
).toBe("off");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("normalizeReasoningLevel", () => {
|
describe("normalizeReasoningLevel", () => {
|
||||||
it("accepts on/off", () => {
|
it("accepts on/off", () => {
|
||||||
expect(normalizeReasoningLevel("on")).toBe("on");
|
expect(normalizeReasoningLevel("on")).toBe("on");
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ export type ElevatedLevel = "off" | "on" | "ask" | "full";
|
|||||||
export type ElevatedMode = "off" | "ask" | "full";
|
export type ElevatedMode = "off" | "ask" | "full";
|
||||||
export type ReasoningLevel = "off" | "on" | "stream";
|
export type ReasoningLevel = "off" | "on" | "stream";
|
||||||
export type UsageDisplayLevel = "off" | "tokens" | "full";
|
export type UsageDisplayLevel = "off" | "tokens" | "full";
|
||||||
|
export type ThinkingCatalogEntry = {
|
||||||
|
provider: string;
|
||||||
|
id: string;
|
||||||
|
reasoning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||||
|
|
||||||
function normalizeProviderId(provider?: string | null): string {
|
function normalizeProviderId(provider?: string | null): string {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -14,6 +21,9 @@ function normalizeProviderId(provider?: string | null): string {
|
|||||||
if (normalized === "z.ai" || normalized === "z-ai") {
|
if (normalized === "z.ai" || normalized === "z-ai") {
|
||||||
return "zai";
|
return "zai";
|
||||||
}
|
}
|
||||||
|
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||||
|
return "amazon-bedrock";
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +140,30 @@ export function formatXHighModelHint(): string {
|
|||||||
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
|
return `${refs.slice(0, -1).join(", ")} or ${refs[refs.length - 1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveThinkingDefaultForModel(params: {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
catalog?: ThinkingCatalogEntry[];
|
||||||
|
}): ThinkLevel {
|
||||||
|
const normalizedProvider = normalizeProviderId(params.provider);
|
||||||
|
const modelLower = params.model.trim().toLowerCase();
|
||||||
|
const isAnthropicFamilyModel =
|
||||||
|
normalizedProvider === "anthropic" ||
|
||||||
|
normalizedProvider === "amazon-bedrock" ||
|
||||||
|
modelLower.includes("anthropic/") ||
|
||||||
|
modelLower.includes(".anthropic.");
|
||||||
|
if (isAnthropicFamilyModel && CLAUDE_46_MODEL_RE.test(modelLower)) {
|
||||||
|
return "adaptive";
|
||||||
|
}
|
||||||
|
const candidate = params.catalog?.find(
|
||||||
|
(entry) => entry.provider === params.provider && entry.id === params.model,
|
||||||
|
);
|
||||||
|
if (candidate?.reasoning) {
|
||||||
|
return "low";
|
||||||
|
}
|
||||||
|
return "off";
|
||||||
|
}
|
||||||
|
|
||||||
type OnOffFullLevel = "off" | "on" | "full";
|
type OnOffFullLevel = "off" | "on" | "full";
|
||||||
|
|
||||||
function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined {
|
function normalizeOnOffFullLevel(raw?: string | null): OnOffFullLevel | undefined {
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export const TelegramDirectSchema = z
|
|||||||
|
|
||||||
const TelegramCustomCommandSchema = z
|
const TelegramCustomCommandSchema = z
|
||||||
.object({
|
.object({
|
||||||
command: z.string().transform(normalizeTelegramCommandName),
|
command: z.string().overwrite(normalizeTelegramCommandName),
|
||||||
description: z.string().transform(normalizeTelegramCommandDescription),
|
description: z.string().overwrite(normalizeTelegramCommandDescription),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { exec } from "node:child_process";
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -529,4 +530,19 @@ export const configHandlers: GatewayRequestHandlers = {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
"config.openFile": ({ params, respond }) => {
|
||||||
|
if (!assertValidParams(params, validateConfigGetParams, "config.openFile", respond)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const configPath = createConfigIO().configPath;
|
||||||
|
const platform = process.platform;
|
||||||
|
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
||||||
|
exec(`${cmd} ${JSON.stringify(configPath)}`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
respond(true, { ok: false, path: configPath, error: err.message }, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
respond(true, { ok: true, path: configPath }, undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
|||||||
|
|
||||||
export const en: TranslationMap = {
|
export const en: TranslationMap = {
|
||||||
common: {
|
common: {
|
||||||
version: "Version",
|
|
||||||
health: "Health",
|
health: "Health",
|
||||||
ok: "OK",
|
ok: "OK",
|
||||||
offline: "Offline",
|
offline: "Offline",
|
||||||
@@ -147,10 +146,6 @@ export const en: TranslationMap = {
|
|||||||
refreshAll: "Refresh All",
|
refreshAll: "Refresh All",
|
||||||
terminal: "Terminal",
|
terminal: "Terminal",
|
||||||
},
|
},
|
||||||
streamMode: {
|
|
||||||
active: "Stream mode — values redacted",
|
|
||||||
disable: "Disable",
|
|
||||||
},
|
|
||||||
palette: {
|
palette: {
|
||||||
placeholder: "Type a command…",
|
placeholder: "Type a command…",
|
||||||
noResults: "No results",
|
noResults: "No results",
|
||||||
@@ -158,7 +153,7 @@ export const en: TranslationMap = {
|
|||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
subtitle: "Gateway Dashboard",
|
subtitle: "Gateway Dashboard",
|
||||||
passwordPlaceholder: "optional", // pragma: allowlist secret
|
passwordPlaceholder: "optional",
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
disconnected: "Disconnected from gateway.",
|
disconnected: "Disconnected from gateway.",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
|||||||
|
|
||||||
export const pt_BR: TranslationMap = {
|
export const pt_BR: TranslationMap = {
|
||||||
common: {
|
common: {
|
||||||
version: "Versão",
|
|
||||||
health: "Saúde",
|
health: "Saúde",
|
||||||
ok: "OK",
|
ok: "OK",
|
||||||
offline: "Offline",
|
offline: "Offline",
|
||||||
@@ -12,7 +11,6 @@ export const pt_BR: TranslationMap = {
|
|||||||
disabled: "Desativado",
|
disabled: "Desativado",
|
||||||
na: "n/a",
|
na: "n/a",
|
||||||
docs: "Docs",
|
docs: "Docs",
|
||||||
theme: "Tema",
|
|
||||||
resources: "Recursos",
|
resources: "Recursos",
|
||||||
search: "Pesquisar",
|
search: "Pesquisar",
|
||||||
},
|
},
|
||||||
@@ -149,10 +147,6 @@ export const pt_BR: TranslationMap = {
|
|||||||
refreshAll: "Atualizar Tudo",
|
refreshAll: "Atualizar Tudo",
|
||||||
terminal: "Terminal",
|
terminal: "Terminal",
|
||||||
},
|
},
|
||||||
streamMode: {
|
|
||||||
active: "Modo stream — valores ocultos",
|
|
||||||
disable: "Desativar",
|
|
||||||
},
|
|
||||||
palette: {
|
palette: {
|
||||||
placeholder: "Digite um comando…",
|
placeholder: "Digite um comando…",
|
||||||
noResults: "Sem resultados",
|
noResults: "Sem resultados",
|
||||||
@@ -160,7 +154,7 @@ export const pt_BR: TranslationMap = {
|
|||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
subtitle: "Painel do Gateway",
|
subtitle: "Painel do Gateway",
|
||||||
passwordPlaceholder: "opcional", // pragma: allowlist secret
|
passwordPlaceholder: "opcional",
|
||||||
},
|
},
|
||||||
chat: {
|
chat: {
|
||||||
disconnected: "Desconectado do gateway.",
|
disconnected: "Desconectado do gateway.",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
|||||||
|
|
||||||
export const zh_CN: TranslationMap = {
|
export const zh_CN: TranslationMap = {
|
||||||
common: {
|
common: {
|
||||||
version: "版本",
|
|
||||||
health: "健康状况",
|
health: "健康状况",
|
||||||
ok: "正常",
|
ok: "正常",
|
||||||
offline: "离线",
|
offline: "离线",
|
||||||
@@ -12,7 +11,6 @@ export const zh_CN: TranslationMap = {
|
|||||||
disabled: "已禁用",
|
disabled: "已禁用",
|
||||||
na: "不适用",
|
na: "不适用",
|
||||||
docs: "文档",
|
docs: "文档",
|
||||||
theme: "主题",
|
|
||||||
resources: "资源",
|
resources: "资源",
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
},
|
},
|
||||||
@@ -146,10 +144,6 @@ export const zh_CN: TranslationMap = {
|
|||||||
refreshAll: "全部刷新",
|
refreshAll: "全部刷新",
|
||||||
terminal: "终端",
|
terminal: "终端",
|
||||||
},
|
},
|
||||||
streamMode: {
|
|
||||||
active: "流模式 — 数据已隐藏",
|
|
||||||
disable: "禁用",
|
|
||||||
},
|
|
||||||
palette: {
|
palette: {
|
||||||
placeholder: "输入命令…",
|
placeholder: "输入命令…",
|
||||||
noResults: "无结果",
|
noResults: "无结果",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { TranslationMap } from "../lib/types.ts";
|
|||||||
|
|
||||||
export const zh_TW: TranslationMap = {
|
export const zh_TW: TranslationMap = {
|
||||||
common: {
|
common: {
|
||||||
version: "版本",
|
|
||||||
health: "健康狀況",
|
health: "健康狀況",
|
||||||
ok: "正常",
|
ok: "正常",
|
||||||
offline: "離線",
|
offline: "離線",
|
||||||
@@ -12,7 +11,6 @@ export const zh_TW: TranslationMap = {
|
|||||||
disabled: "已禁用",
|
disabled: "已禁用",
|
||||||
na: "不適用",
|
na: "不適用",
|
||||||
docs: "文檔",
|
docs: "文檔",
|
||||||
theme: "主題",
|
|
||||||
resources: "資源",
|
resources: "資源",
|
||||||
search: "搜尋",
|
search: "搜尋",
|
||||||
},
|
},
|
||||||
@@ -146,10 +144,6 @@ export const zh_TW: TranslationMap = {
|
|||||||
refreshAll: "全部刷新",
|
refreshAll: "全部刷新",
|
||||||
terminal: "終端",
|
terminal: "終端",
|
||||||
},
|
},
|
||||||
streamMode: {
|
|
||||||
active: "串流模式 — 數據已隱藏",
|
|
||||||
disable: "禁用",
|
|
||||||
},
|
|
||||||
palette: {
|
palette: {
|
||||||
placeholder: "輸入指令…",
|
placeholder: "輸入指令…",
|
||||||
noResults: "無結果",
|
noResults: "無結果",
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
@import "./styles/layout.css";
|
@import "./styles/layout.css";
|
||||||
@import "./styles/layout.mobile.css";
|
@import "./styles/layout.mobile.css";
|
||||||
@import "./styles/components.css";
|
@import "./styles/components.css";
|
||||||
|
@import "./styles/chat.css";
|
||||||
@import "./styles/config.css";
|
@import "./styles/config.css";
|
||||||
|
|||||||
@@ -1,78 +1,78 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Background - Warmer dark with depth */
|
/* Background - Deep, rich dark with layered depth */
|
||||||
--bg: #12141a;
|
--bg: #0e1015;
|
||||||
--bg-accent: #14161d;
|
--bg-accent: #13151b;
|
||||||
--bg-elevated: #1a1d25;
|
--bg-elevated: #191c24;
|
||||||
--bg-hover: #262a35;
|
--bg-hover: #1f2330;
|
||||||
--bg-muted: #262a35;
|
--bg-muted: #1f2330;
|
||||||
|
|
||||||
/* Card / Surface - More contrast between levels */
|
/* Card / Surface - Clear hierarchy between levels */
|
||||||
--card: #181b22;
|
--card: #161920;
|
||||||
--card-foreground: #f4f4f5;
|
--card-foreground: #f0f0f2;
|
||||||
--card-highlight: rgba(255, 255, 255, 0.05);
|
--card-highlight: rgba(255, 255, 255, 0.04);
|
||||||
--popover: #181b22;
|
--popover: #191c24;
|
||||||
--popover-foreground: #f4f4f5;
|
--popover-foreground: #f0f0f2;
|
||||||
|
|
||||||
/* Panel */
|
/* Panel */
|
||||||
--panel: #12141a;
|
--panel: #0e1015;
|
||||||
--panel-strong: #1a1d25;
|
--panel-strong: #191c24;
|
||||||
--panel-hover: #262a35;
|
--panel-hover: #1f2330;
|
||||||
--chrome: rgba(18, 20, 26, 0.95);
|
--chrome: rgba(14, 16, 21, 0.96);
|
||||||
--chrome-strong: rgba(18, 20, 26, 0.98);
|
--chrome-strong: rgba(14, 16, 21, 0.98);
|
||||||
|
|
||||||
/* Text - Slightly warmer */
|
/* Text - Clean contrast */
|
||||||
--text: #e4e4e7;
|
--text: #d4d4d8;
|
||||||
--text-strong: #fafafa;
|
--text-strong: #f4f4f5;
|
||||||
--chat-text: #e4e4e7;
|
--chat-text: #d4d4d8;
|
||||||
--muted: #71717a;
|
--muted: #636370;
|
||||||
--muted-strong: #52525b;
|
--muted-strong: #4e4e5a;
|
||||||
--muted-foreground: #71717a;
|
--muted-foreground: #636370;
|
||||||
|
|
||||||
/* Border - Subtle but defined */
|
/* Border - Whisper-thin, barely there */
|
||||||
--border: #27272a;
|
--border: #1e2028;
|
||||||
--border-strong: #3f3f46;
|
--border-strong: #2e3040;
|
||||||
--border-hover: #52525b;
|
--border-hover: #3e4050;
|
||||||
--input: #27272a;
|
--input: #1e2028;
|
||||||
--ring: #ff5c5c;
|
--ring: #ff5c5c;
|
||||||
|
|
||||||
/* Accent - Punchy signature red */
|
/* Accent - Punchy signature red */
|
||||||
--accent: #ff5c5c;
|
--accent: #ff5c5c;
|
||||||
--accent-hover: #ff7070;
|
--accent-hover: #ff7070;
|
||||||
--accent-muted: #ff5c5c;
|
--accent-muted: #ff5c5c;
|
||||||
--accent-subtle: rgba(255, 92, 92, 0.15);
|
--accent-subtle: rgba(255, 92, 92, 0.1);
|
||||||
--accent-foreground: #fafafa;
|
--accent-foreground: #fafafa;
|
||||||
--accent-glow: rgba(255, 92, 92, 0.25);
|
--accent-glow: rgba(255, 92, 92, 0.2);
|
||||||
--primary: #ff5c5c;
|
--primary: #ff5c5c;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
/* Secondary - Teal accent for variety */
|
/* Secondary */
|
||||||
--secondary: #1e2028;
|
--secondary: #161920;
|
||||||
--secondary-foreground: #f4f4f5;
|
--secondary-foreground: #f0f0f2;
|
||||||
--accent-2: #14b8a6;
|
--accent-2: #14b8a6;
|
||||||
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
--accent-2-muted: rgba(20, 184, 166, 0.7);
|
||||||
--accent-2-subtle: rgba(20, 184, 166, 0.15);
|
--accent-2-subtle: rgba(20, 184, 166, 0.1);
|
||||||
|
|
||||||
/* Semantic - More saturated */
|
/* Semantic */
|
||||||
--ok: #22c55e;
|
--ok: #22c55e;
|
||||||
--ok-muted: rgba(34, 197, 94, 0.75);
|
--ok-muted: rgba(34, 197, 94, 0.75);
|
||||||
--ok-subtle: rgba(34, 197, 94, 0.12);
|
--ok-subtle: rgba(34, 197, 94, 0.08);
|
||||||
--destructive: #ef4444;
|
--destructive: #ef4444;
|
||||||
--destructive-foreground: #fafafa;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #f59e0b;
|
--warn: #f59e0b;
|
||||||
--warn-muted: rgba(245, 158, 11, 0.75);
|
--warn-muted: rgba(245, 158, 11, 0.75);
|
||||||
--warn-subtle: rgba(245, 158, 11, 0.12);
|
--warn-subtle: rgba(245, 158, 11, 0.08);
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--danger-muted: rgba(239, 68, 68, 0.75);
|
--danger-muted: rgba(239, 68, 68, 0.75);
|
||||||
--danger-subtle: rgba(239, 68, 68, 0.12);
|
--danger-subtle: rgba(239, 68, 68, 0.08);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Focus - With glow */
|
/* Focus */
|
||||||
--focus: rgba(255, 92, 92, 0.25);
|
--focus: rgba(255, 92, 92, 0.2);
|
||||||
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);
|
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 60%, transparent);
|
||||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 20px var(--accent-glow);
|
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 16px var(--accent-glow);
|
||||||
|
|
||||||
/* Grid */
|
/* Grid */
|
||||||
--grid-line: rgba(255, 255, 255, 0.04);
|
--grid-line: rgba(255, 255, 255, 0.03);
|
||||||
|
|
||||||
/* Theme transition */
|
/* Theme transition */
|
||||||
--theme-switch-x: 50%;
|
--theme-switch-x: 50%;
|
||||||
@@ -81,111 +81,153 @@
|
|||||||
/* Typography */
|
/* Typography */
|
||||||
--mono:
|
--mono:
|
||||||
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||||
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-display: var(--font-body);
|
--font-display: var(--font-body);
|
||||||
|
|
||||||
/* Shadows - Richer with subtle color */
|
/* Shadows - Subtle, layered depth */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.03);
|
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-glow: 0 0 30px var(--accent-glow);
|
--shadow-glow: 0 0 24px var(--accent-glow);
|
||||||
|
|
||||||
/* Radii - Slightly larger for friendlier feel */
|
/* Radii - Slightly larger for modern feel */
|
||||||
--radius-sm: 6px;
|
--radius-sm: 6px;
|
||||||
--radius-md: 8px;
|
--radius-md: 10px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 14px;
|
||||||
--radius-xl: 16px;
|
--radius-xl: 20px;
|
||||||
--radius-full: 9999px;
|
--radius-full: 9999px;
|
||||||
--radius: 8px;
|
--radius: 10px;
|
||||||
|
|
||||||
/* Transitions - Snappy but smooth */
|
/* Transitions - Crisp and responsive */
|
||||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
--duration-fast: 120ms;
|
--duration-fast: 100ms;
|
||||||
--duration-normal: 200ms;
|
--duration-normal: 180ms;
|
||||||
--duration-slow: 350ms;
|
--duration-slow: 300ms;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme - Clean with subtle warmth */
|
/* Light theme tokens apply to every light-mode family. */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme-mode="light"] {
|
||||||
--bg: #fafafa;
|
--bg: #f8f9fa;
|
||||||
--bg-accent: #f5f5f5;
|
--bg-accent: #f1f3f5;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #ffffff;
|
||||||
--bg-hover: #f0f0f0;
|
--bg-hover: #eceef0;
|
||||||
--bg-muted: #f0f0f0;
|
--bg-muted: #eceef0;
|
||||||
--bg-content: #f5f5f5;
|
--bg-content: #f1f3f5;
|
||||||
|
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--card-foreground: #18181b;
|
--card-foreground: #1a1a1e;
|
||||||
--card-highlight: rgba(0, 0, 0, 0.03);
|
--card-highlight: rgba(0, 0, 0, 0.02);
|
||||||
--popover: #ffffff;
|
--popover: #ffffff;
|
||||||
--popover-foreground: #18181b;
|
--popover-foreground: #1a1a1e;
|
||||||
|
|
||||||
--panel: #fafafa;
|
--panel: #f8f9fa;
|
||||||
--panel-strong: #f5f5f5;
|
--panel-strong: #f1f3f5;
|
||||||
--panel-hover: #ebebeb;
|
--panel-hover: #e6e8eb;
|
||||||
--chrome: rgba(250, 250, 250, 0.95);
|
--chrome: rgba(248, 249, 250, 0.96);
|
||||||
--chrome-strong: rgba(250, 250, 250, 0.98);
|
--chrome-strong: rgba(248, 249, 250, 0.98);
|
||||||
|
|
||||||
--text: #3f3f46;
|
--text: #3c3c43;
|
||||||
--text-strong: #18181b;
|
--text-strong: #1a1a1e;
|
||||||
--chat-text: #3f3f46;
|
--chat-text: #3c3c43;
|
||||||
--muted: #71717a;
|
--muted: #8e8e93;
|
||||||
--muted-strong: #52525b;
|
--muted-strong: #636366;
|
||||||
--muted-foreground: #71717a;
|
--muted-foreground: #8e8e93;
|
||||||
|
|
||||||
--border: #e4e4e7;
|
--border: #e5e5ea;
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d1d6;
|
||||||
--border-hover: #a1a1aa;
|
--border-hover: #aeaeb2;
|
||||||
--input: #e4e4e7;
|
--input: #e5e5ea;
|
||||||
|
|
||||||
--accent: #dc2626;
|
--accent: #dc2626;
|
||||||
--accent-hover: #ef4444;
|
--accent-hover: #ef4444;
|
||||||
--accent-muted: #dc2626;
|
--accent-muted: #dc2626;
|
||||||
--accent-subtle: rgba(220, 38, 38, 0.12);
|
--accent-subtle: rgba(220, 38, 38, 0.08);
|
||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
--accent-glow: rgba(220, 38, 38, 0.15);
|
--accent-glow: rgba(220, 38, 38, 0.1);
|
||||||
--primary: #dc2626;
|
--primary: #dc2626;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
|
|
||||||
--secondary: #f4f4f5;
|
--secondary: #f1f3f5;
|
||||||
--secondary-foreground: #3f3f46;
|
--secondary-foreground: #3c3c43;
|
||||||
--accent-2: #0d9488;
|
--accent-2: #0d9488;
|
||||||
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
--accent-2-muted: rgba(13, 148, 136, 0.75);
|
||||||
--accent-2-subtle: rgba(13, 148, 136, 0.12);
|
--accent-2-subtle: rgba(13, 148, 136, 0.08);
|
||||||
|
|
||||||
--ok: #16a34a;
|
--ok: #16a34a;
|
||||||
--ok-muted: rgba(22, 163, 74, 0.75);
|
--ok-muted: rgba(22, 163, 74, 0.75);
|
||||||
--ok-subtle: rgba(22, 163, 74, 0.1);
|
--ok-subtle: rgba(22, 163, 74, 0.08);
|
||||||
--destructive: #dc2626;
|
--destructive: #dc2626;
|
||||||
--destructive-foreground: #fafafa;
|
--destructive-foreground: #fafafa;
|
||||||
--warn: #d97706;
|
--warn: #d97706;
|
||||||
--warn-muted: rgba(217, 119, 6, 0.75);
|
--warn-muted: rgba(217, 119, 6, 0.75);
|
||||||
--warn-subtle: rgba(217, 119, 6, 0.1);
|
--warn-subtle: rgba(217, 119, 6, 0.08);
|
||||||
--danger: #dc2626;
|
--danger: #dc2626;
|
||||||
--danger-muted: rgba(220, 38, 38, 0.75);
|
--danger-muted: rgba(220, 38, 38, 0.75);
|
||||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
--danger-subtle: rgba(220, 38, 38, 0.08);
|
||||||
--info: #2563eb;
|
--info: #2563eb;
|
||||||
|
|
||||||
--focus: rgba(220, 38, 38, 0.2);
|
--focus: rgba(220, 38, 38, 0.15);
|
||||||
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring), 0 0 16px var(--accent-glow);
|
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||||
|
--focus-glow: 0 0 0 2px var(--bg), 0 0 0 3px var(--ring), 0 0 12px var(--accent-glow);
|
||||||
|
|
||||||
--grid-line: rgba(0, 0, 0, 0.05);
|
--grid-line: rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
/* Light shadows */
|
/* Light shadows - Subtle, clean */
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
--shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
--shadow-xl: 0 24px 48px rgba(0, 0, 0, 0.1);
|
||||||
--shadow-glow: 0 0 24px var(--accent-glow);
|
--shadow-glow: 0 0 20px var(--accent-glow);
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Theme families override accent tokens while keeping shared surfaces/layout. */
|
||||||
|
:root[data-theme="openknot"] {
|
||||||
|
--ring: #14b8a6;
|
||||||
|
--accent: #14b8a6;
|
||||||
|
--accent-hover: #2dd4bf;
|
||||||
|
--accent-muted: #14b8a6;
|
||||||
|
--accent-subtle: rgba(20, 184, 166, 0.12);
|
||||||
|
--accent-glow: rgba(20, 184, 166, 0.22);
|
||||||
|
--primary: #14b8a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="openknot-light"] {
|
||||||
|
--ring: #0d9488;
|
||||||
|
--accent: #0d9488;
|
||||||
|
--accent-hover: #0f766e;
|
||||||
|
--accent-muted: #0d9488;
|
||||||
|
--accent-subtle: rgba(13, 148, 136, 0.1);
|
||||||
|
--accent-glow: rgba(13, 148, 136, 0.14);
|
||||||
|
--primary: #0d9488;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dash"] {
|
||||||
|
--ring: #3b82f6;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #60a5fa;
|
||||||
|
--accent-muted: #3b82f6;
|
||||||
|
--accent-subtle: rgba(59, 130, 246, 0.14);
|
||||||
|
--accent-glow: rgba(59, 130, 246, 0.22);
|
||||||
|
--primary: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dash-light"] {
|
||||||
|
--ring: #2563eb;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--accent-muted: #2563eb;
|
||||||
|
--accent-subtle: rgba(37, 99, 235, 0.1);
|
||||||
|
--accent-glow: rgba(37, 99, 235, 0.14);
|
||||||
|
--primary: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -197,8 +239,8 @@ body {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font: 400 14px/1.55 var(--font-body);
|
font: 400 13.5px/1.55 var(--font-body);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.01em;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
@@ -267,10 +309,10 @@ select {
|
|||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling - Minimal, barely visible */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
height: 8px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@@ -278,12 +320,12 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--border);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--border-strong);
|
background: rgba(255, 255, 255, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations - Polished with spring feel */
|
/* Animations - Polished with spring feel */
|
||||||
@@ -338,6 +380,42 @@ select {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading primitives */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, var(--bg-muted) 25%, var(--bg-hover) 50%, var(--bg-muted) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--short {
|
||||||
|
width: 40%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--medium {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--long {
|
||||||
|
width: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stat {
|
||||||
|
height: 28px;
|
||||||
|
width: 60px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-block {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse-subtle {
|
@keyframes pulse-subtle {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
/* Chat Group Layout - default (assistant/other on left) */
|
/* Chat Group Layout - default (assistant/other on left) */
|
||||||
.chat-group {
|
.chat-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,52 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Group footer action buttons (TTS, delete) ── */
|
||||||
|
.chat-group-footer button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 120ms ease-out, color 120ms ease-out, background 120ms ease-out;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group:hover .chat-group-footer button {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group-footer button:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: var(--bg-hover, rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group-footer button svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-btn--active {
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
color: var(--accent, #3b82f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-group-delete:hover {
|
||||||
|
color: var(--danger, #ef4444) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat divider (e.g., compaction marker) */
|
/* Chat divider (e.g., compaction marker) */
|
||||||
.chat-divider {
|
.chat-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -83,22 +129,24 @@
|
|||||||
|
|
||||||
/* Avatar Styles */
|
/* Avatar Styles */
|
||||||
.chat-avatar {
|
.chat-avatar {
|
||||||
width: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
background: var(--panel-strong);
|
background: var(--panel-strong);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-self: flex-end; /* Align with last message in group */
|
align-self: flex-end;
|
||||||
margin-bottom: 4px; /* Optical alignment */
|
margin-bottom: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar.user {
|
.chat-avatar.user {
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-avatar.assistant {
|
.chat-avatar.assistant {
|
||||||
@@ -127,14 +175,14 @@ img.chat-avatar {
|
|||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1px solid transparent;
|
border: 1px solid var(--border);
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transition:
|
transition:
|
||||||
background 150ms ease-out,
|
background var(--duration-fast) ease-out,
|
||||||
border-color 150ms ease-out;
|
border-color var(--duration-fast) ease-out;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -244,7 +292,7 @@ img.chat-avatar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode: restore borders */
|
/* Light mode: restore borders */
|
||||||
:root[data-theme="light"] .chat-bubble {
|
:root[data-theme-mode="light"] .chat-bubble {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
box-shadow: inset 0 1px 0 var(--card-highlight);
|
||||||
}
|
}
|
||||||
@@ -259,7 +307,7 @@ img.chat-avatar {
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-group.user .chat-bubble {
|
:root[data-theme-mode="light"] .chat-group.user .chat-bubble {
|
||||||
border-color: rgba(234, 88, 12, 0.2);
|
border-color: rgba(234, 88, 12, 0.2);
|
||||||
background: rgba(251, 146, 60, 0.12);
|
background: rgba(251, 146, 60, 0.12);
|
||||||
}
|
}
|
||||||
@@ -298,3 +346,125 @@ img.chat-avatar {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Message metadata (tokens, cost, model, context %) ── */
|
||||||
|
.msg-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-meta__tokens,
|
||||||
|
.msg-meta__cache,
|
||||||
|
.msg-meta__cost,
|
||||||
|
.msg-meta__ctx,
|
||||||
|
.msg-meta__model {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-meta__model {
|
||||||
|
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-meta__cost {
|
||||||
|
color: var(--ok, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-meta__ctx--warn {
|
||||||
|
color: var(--warning, #eab308);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-meta__ctx--danger {
|
||||||
|
color: var(--danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete confirmation popover ── */
|
||||||
|
.chat-delete-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
background: var(--card, #1a1a1a);
|
||||||
|
border: 1px solid var(--border, rgba(255,255,255,0.1));
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
animation: scale-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__text {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__remember {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted, #888);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__check {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
accent-color: var(--accent, #3b82f6);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__cancel,
|
||||||
|
.chat-delete-confirm__yes {
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__cancel {
|
||||||
|
background: var(--bg-hover, rgba(255,255,255,0.08));
|
||||||
|
color: var(--muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__cancel:hover {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__yes {
|
||||||
|
background: var(--danger, #ef4444);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-delete-confirm__yes:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,17 +219,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme attachment overrides */
|
/* Light theme attachment overrides */
|
||||||
:root[data-theme="light"] .chat-attachments {
|
:root[data-theme-mode="light"] .chat-attachments {
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-color: rgba(16, 24, 40, 0.1);
|
border-color: rgba(16, 24, 40, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-attachment {
|
:root[data-theme-mode="light"] .chat-attachment {
|
||||||
border-color: rgba(16, 24, 40, 0.15);
|
border-color: rgba(16, 24, 40, 0.15);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-attachment__remove {
|
:root[data-theme-mode="light"] .chat-attachment__remove {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-compose {
|
:root[data-theme-mode="light"] .chat-compose {
|
||||||
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +322,340 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__input {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 18px 14px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
border-color var(--duration-fast) ease,
|
||||||
|
box-shadow var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input:focus-within {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (backdrop-filter: blur(1px)) {
|
||||||
|
.agent-chat__input {
|
||||||
|
backdrop-filter: blur(12px) saturate(1.6);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input > textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 150px;
|
||||||
|
resize: none;
|
||||||
|
padding: 12px 14px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.4;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input > textarea::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__toolbar-left,
|
||||||
|
.agent-chat__toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn,
|
||||||
|
.agent-chat__toolbar .btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn svg,
|
||||||
|
.agent-chat__toolbar .btn-ghost svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn:hover:not(:disabled),
|
||||||
|
.agent-chat__toolbar .btn-ghost:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn:disabled,
|
||||||
|
.agent-chat__toolbar .btn-ghost:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn--active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__token-count {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
background var(--duration-fast) ease,
|
||||||
|
box-shadow var(--duration-fast) ease;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
box-shadow: 0 2px 10px rgba(255, 92, 92, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn--stop {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-send-btn--stop:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--danger) 85%, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 30;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-group + .slash-menu-group {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-group__label {
|
||||||
|
padding: 4px 10px 2px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--duration-fast) ease,
|
||||||
|
color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-item:hover,
|
||||||
|
.slash-menu-item--active {
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-item--active .slash-menu-icon,
|
||||||
|
.slash-menu-item:hover .slash-menu-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-args {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-desc {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-item--active .slash-menu-name {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slash-menu-item--active .slash-menu-desc {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachments-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-attachment-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Chat controls - moved to content-header area, left aligned */
|
/* Chat controls - moved to content-header area, left aligned */
|
||||||
.chat-controls {
|
.chat-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -363,7 +697,7 @@
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-controls__separator {
|
:root[data-theme-mode="light"] .chat-controls__separator {
|
||||||
color: rgba(16, 24, 40, 0.3);
|
color: rgba(16, 24, 40, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,34 +707,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme icon button overrides */
|
/* Light theme icon button overrides */
|
||||||
:root[data-theme="light"] .btn--icon {
|
:root[data-theme-mode="light"] .btn--icon {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .btn--icon:hover {
|
:root[data-theme-mode="light"] .btn--icon:hover {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme icon button overrides */
|
/* Light theme icon button overrides */
|
||||||
:root[data-theme="light"] .btn--icon {
|
:root[data-theme-mode="light"] .btn--icon {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .btn--icon:hover {
|
:root[data-theme-mode="light"] .btn--icon:hover {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-color: var(--border-strong);
|
border-color: var(--border-strong);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-controls .btn--icon.active {
|
:root[data-theme-mode="light"] .chat-controls .btn--icon.active {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -438,7 +772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme thinking indicator override */
|
/* Light theme thinking indicator override */
|
||||||
:root[data-theme="light"] .chat-controls__thinking {
|
:root[data-theme-mode="light"] .chat-controls__thinking {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
border-color: rgba(16, 24, 40, 0.15);
|
border-color: rgba(16, 24, 40, 0.15);
|
||||||
}
|
}
|
||||||
@@ -479,3 +813,117 @@
|
|||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chat loading skeleton */
|
||||||
|
.chat-loading-skeleton {
|
||||||
|
padding: 4px 0;
|
||||||
|
animation: fade-in 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome state (new session) */
|
||||||
|
.agent-chat__welcome {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__welcome-glow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__welcome h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__avatar--logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__avatar--logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__badge img {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__hint kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__suggestions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 480px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__suggestion {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 100px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__suggestion:hover {
|
||||||
|
background: var(--panel-strong);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-thinking {
|
:root[data-theme-mode="light"] .chat-thinking {
|
||||||
border-color: rgba(16, 24, 40, 0.25);
|
border-color: rgba(16, 24, 40, 0.25);
|
||||||
background: rgba(16, 24, 40, 0.04);
|
background: rgba(16, 24, 40, 0.04);
|
||||||
}
|
}
|
||||||
@@ -97,24 +97,24 @@
|
|||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-text :where(blockquote) {
|
:root[data-theme-mode="light"] .chat-text :where(blockquote) {
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote) {
|
:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote) {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-text :where(blockquote blockquote blockquote) {
|
:root[data-theme-mode="light"] .chat-text :where(blockquote blockquote blockquote) {
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
|
:root[data-theme-mode="light"] .chat-text :where(:not(pre) > code) {
|
||||||
background: rgba(0, 0, 0, 0.08);
|
background: rgba(0, 0, 0, 0.08);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .chat-text :where(pre) {
|
:root[data-theme-mode="light"] .chat-text :where(pre) {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
/* Tool Card Styles */
|
/* Tool Card Styles */
|
||||||
.chat-tool-card {
|
.chat-tool-card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-md);
|
||||||
padding: 12px;
|
padding: 10px 12px;
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
box-shadow: inset 0 1px 0 var(--card-highlight);
|
|
||||||
transition:
|
transition:
|
||||||
border-color 150ms ease-out,
|
border-color var(--duration-fast) ease-out,
|
||||||
background 150ms ease-out;
|
background var(--duration-fast) ease-out;
|
||||||
/* Fixed max-height to ensure cards don't expand too much */
|
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -154,6 +152,265 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary::before {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-collapse[open] > .chat-tools-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary__icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary__count {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-summary__names {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-collapse__body {
|
||||||
|
padding: 4px 12px 12px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tools-collapse__body .chat-tool-card:first-child {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-collapse {
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--secondary) 60%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
background 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-summary::before {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-collapse[open] > .chat-json-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--bg-hover) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-json-content code {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-collapse {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--bg-hover) 35%, transparent);
|
||||||
|
transition:
|
||||||
|
color 150ms ease,
|
||||||
|
background 150ms ease,
|
||||||
|
border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary::before {
|
||||||
|
content: "▸";
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-collapse[open] > .chat-tool-msg-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--bg-hover) 60%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.75;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary__icon svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary__label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary__names {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-summary__preview {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
flex: 1 1 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-msg-body {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reading Indicator */
|
/* Reading Indicator */
|
||||||
.chat-reading-indicator {
|
.chat-reading-indicator {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@
|
|||||||
--shell-pad: 16px;
|
--shell-pad: 16px;
|
||||||
--shell-gap: 16px;
|
--shell-gap: 16px;
|
||||||
--shell-nav-width: 220px;
|
--shell-nav-width: 220px;
|
||||||
--shell-topbar-height: 56px;
|
--shell-nav-rail-width: 72px;
|
||||||
|
--shell-topbar-height: 52px;
|
||||||
--shell-focus-duration: 200ms;
|
--shell-focus-duration: 200ms;
|
||||||
--shell-focus-ease: var(--ease-out);
|
--shell-focus-ease: var(--ease-out);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
"topbar topbar"
|
"topbar topbar"
|
||||||
"nav content";
|
"nav content";
|
||||||
gap: 0;
|
gap: 0;
|
||||||
animation: dashboard-enter 0.4s var(--ease-out);
|
animation: dashboard-enter 0.3s var(--ease-out);
|
||||||
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
|
transition: grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell--nav-collapsed {
|
.shell--nav-collapsed {
|
||||||
grid-template-columns: 0px minmax(0, 1fr);
|
grid-template-columns: var(--shell-nav-rail-width) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell--chat-focus {
|
.shell--chat-focus {
|
||||||
@@ -84,7 +85,9 @@
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
height: var(--shell-topbar-height);
|
height: var(--shell-topbar-height);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: color-mix(in srgb, var(--bg) 85%, transparent);
|
||||||
|
backdrop-filter: blur(12px) saturate(1.6);
|
||||||
|
-webkit-backdrop-filter: blur(12px) saturate(1.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-left {
|
.topbar-left {
|
||||||
@@ -113,12 +116,12 @@
|
|||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 28px;
|
width: 26px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +134,11 @@
|
|||||||
.brand-text {
|
.brand-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
@@ -143,10 +146,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
.brand-sub {
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
@@ -179,93 +182,389 @@
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-status .theme-toggle {
|
.topbar-status .theme-orb__trigger {
|
||||||
--theme-item: 24px;
|
width: 26px;
|
||||||
--theme-gap: 2px;
|
height: 26px;
|
||||||
--theme-pad: 3px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-status .theme-icon {
|
/* Topbar search trigger */
|
||||||
width: 12px;
|
.topbar-search {
|
||||||
height: 12px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--duration-fast) ease,
|
||||||
|
background var(--duration-fast) ease,
|
||||||
|
color var(--duration-fast) ease;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search:hover {
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search__label {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search__kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode__btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: calc(var(--radius-md) - 1px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--duration-fast) ease,
|
||||||
|
background var(--duration-fast) ease,
|
||||||
|
border-color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode__btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode__btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: var(--focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode__btn--active {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-theme-mode__btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.75px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Navigation Sidebar
|
Navigation Sidebar (shadcn-inspired)
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.nav {
|
/* Sidebar wrapper – occupies the "nav" grid area */
|
||||||
|
.shell-nav {
|
||||||
grid-area: nav;
|
grid-area: nav;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width var(--shell-focus-duration) var(--shell-focus-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sidebar panel itself */
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme-mode="light"] .sidebar {
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed: icon-only rail */
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: var(--shell-nav-rail-width);
|
||||||
|
min-width: var(--shell-nav-rail-width);
|
||||||
|
flex: 0 0 var(--shell-nav-rail-width);
|
||||||
|
border-right: 1px solid color-mix(in srgb, var(--border-strong) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header: brand + collapse toggle */
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 14px 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 10px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Brand lockup */
|
||||||
|
.sidebar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand__logo {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.025em;
|
||||||
|
color: var(--text-strong);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollable nav body */
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
padding: 16px 12px;
|
padding: 4px 8px;
|
||||||
background: var(--bg);
|
scrollbar-width: none;
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
transition:
|
|
||||||
width var(--shell-focus-duration) var(--shell-focus-ease),
|
|
||||||
padding var(--shell-focus-duration) var(--shell-focus-ease),
|
|
||||||
opacity var(--shell-focus-duration) var(--shell-focus-ease);
|
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav::-webkit-scrollbar {
|
.sidebar-nav::-webkit-scrollbar {
|
||||||
display: none; /* Chrome/Safari */
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell--chat-focus .nav {
|
.sidebar--collapsed .sidebar-nav {
|
||||||
width: 0;
|
padding: 4px 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsed sidebar: centre icons, hide text */
|
||||||
|
.sidebar--collapsed .nav-group__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-group {
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In collapsed sidebar, always show nav items (icon-only) regardless of group collapse state */
|
||||||
|
.sidebar--collapsed .nav-group--collapsed .nav-group__items {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 42px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-width: 0;
|
margin: 0 auto;
|
||||||
overflow: hidden;
|
border-radius: 16px;
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav--collapsed {
|
.sidebar--collapsed .nav-item__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item__icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item__text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item__external-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer: docs link + version */
|
||||||
|
.sidebar-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-footer {
|
||||||
|
padding: 12px 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer__docs-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-footer__docs-block {
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .sidebar-footer .nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version__text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-version__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: color-mix(in srgb, var(--accent) 78%, white 22%);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||||
|
opacity: 1;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag-to-resize handle */
|
||||||
|
.sidebar-resizer {
|
||||||
|
width: 3px;
|
||||||
|
cursor: col-resize;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
transition: background var(--duration-fast) ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-resizer::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-resizer:hover::after {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-resizer:active::after {
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shell-level collapsed / focus overrides */
|
||||||
|
.shell--nav-collapsed .shell-nav {
|
||||||
|
width: var(--shell-nav-rail-width);
|
||||||
|
min-width: var(--shell-nav-rail-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell--chat-focus .shell-nav {
|
||||||
width: 0;
|
width: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: none;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav collapse toggle */
|
/* Nav collapse toggle */
|
||||||
.nav-collapse-toggle {
|
.nav-collapse-toggle {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
background var(--duration-fast) ease,
|
background var(--duration-fast) ease,
|
||||||
border-color var(--duration-fast) ease;
|
border-color var(--duration-fast) ease,
|
||||||
margin-bottom: 16px;
|
color var(--duration-fast) ease;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-collapse-toggle:hover {
|
.nav-collapse-toggle:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: var(--border);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-collapse-toggle__icon {
|
.nav-collapse-toggle__icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
color: var(--muted);
|
color: inherit;
|
||||||
transition: color var(--duration-fast) ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-collapse-toggle__icon svg {
|
.nav-collapse-toggle__icon svg {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 1.5px;
|
stroke-width: 1.5px;
|
||||||
@@ -274,14 +573,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
.nav-collapse-toggle:hover .nav-collapse-toggle__icon {
|
||||||
color: var(--text);
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav groups */
|
/* Nav groups */
|
||||||
.nav-group {
|
.nav-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group:last-child {
|
.nav-group:last-child {
|
||||||
@@ -297,53 +596,67 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav label */
|
.nav-group__label {
|
||||||
.nav-label {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: 5px 10px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition:
|
transition:
|
||||||
color var(--duration-fast) ease,
|
color var(--duration-fast) ease,
|
||||||
background var(--duration-fast) ease;
|
background var(--duration-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label:hover {
|
.nav-group__label:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label--static {
|
.nav-group__label--static {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label--static:hover {
|
.nav-group__label--static:hover {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label__text {
|
.nav-group__label-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label__chevron {
|
.nav-group__chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
transition: transform var(--duration-fast) ease;
|
transition: transform var(--duration-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group--collapsed .nav-label__chevron {
|
.nav-group__chevron svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group--collapsed .nav-group__chevron {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,8 +666,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -368,19 +681,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item__icon {
|
.nav-item__icon {
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0.7;
|
opacity: 0.6;
|
||||||
transition: opacity var(--duration-fast) ease;
|
transition: opacity var(--duration-fast) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item__icon svg {
|
.nav-item__icon svg {
|
||||||
width: 16px;
|
width: 15px;
|
||||||
height: 16px;
|
height: 15px;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke-width: 1.5px;
|
stroke-width: 1.5px;
|
||||||
@@ -390,7 +703,7 @@
|
|||||||
|
|
||||||
.nav-item__text {
|
.nav-item__text {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 450;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,26 +714,91 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover .nav-item__icon {
|
.nav-item:hover .nav-item__icon {
|
||||||
opacity: 1;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active,
|
||||||
|
.nav-item--active {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
background: var(--accent-subtle);
|
background: var(--accent-subtle);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active .nav-item__icon {
|
.nav-item.active .nav-item__icon,
|
||||||
|
.nav-item--active .nav-item__icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item--active::before,
|
||||||
|
.sidebar--collapsed .nav-item.active::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 11px;
|
||||||
|
bottom: 11px;
|
||||||
|
width: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item.active,
|
||||||
|
.sidebar--collapsed .nav-item--active {
|
||||||
|
background: color-mix(in srgb, var(--accent-subtle) 88%, var(--bg-elevated) 12%);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 12%, var(--border) 88%);
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-collapse-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 34px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-color: color-mix(in srgb, var(--border-strong) 74%, transparent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 92%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent),
|
||||||
|
0 8px 18px color-mix(in srgb, black 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-collapse-toggle:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--border-strong) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-elevated) 96%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item__external-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item__external-icon svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover .nav-item__external-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================================
|
/* ===========================================
|
||||||
Content Area
|
Content Area
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
padding: 12px 16px 32px;
|
padding: 16px 20px 32px;
|
||||||
display: block;
|
display: block;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -428,10 +806,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content>*+* {
|
.content>*+* {
|
||||||
margin-top: 24px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] .content {
|
:root[data-theme-mode="light"] .content {
|
||||||
background: var(--bg-content);
|
background: var(--bg-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,19 +851,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 26px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 650;
|
||||||
letter-spacing: -0.035em;
|
letter-spacing: -0.03em;
|
||||||
line-height: 1.15;
|
line-height: 1.2;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-sub {
|
.page-sub {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-top: 6px;
|
margin-top: 4px;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.005em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-meta {
|
.page-meta {
|
||||||
@@ -577,18 +955,6 @@
|
|||||||
"content";
|
"content";
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
|
||||||
position: static;
|
|
||||||
max-height: none;
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
|
|||||||
@@ -2,45 +2,102 @@
|
|||||||
Mobile Layout
|
Mobile Layout
|
||||||
=========================================== */
|
=========================================== */
|
||||||
|
|
||||||
/* Tablet: Horizontal nav */
|
/* Tablet and smaller: collapse the left nav into a horizontal rail. */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.nav {
|
.shell,
|
||||||
|
.shell--nav-collapsed {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: var(--shell-topbar-height) auto minmax(0, 1fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"topbar"
|
||||||
|
"nav"
|
||||||
|
"content";
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell--chat-focus {
|
||||||
|
grid-template-rows: var(--shell-topbar-height) 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-nav,
|
||||||
|
.shell--nav-collapsed .shell-nav {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.sidebar--collapsed {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header,
|
||||||
|
.sidebar--collapsed .sidebar-header {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 8px 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav,
|
||||||
|
.sidebar--collapsed .sidebar-nav {
|
||||||
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
padding: 10px 14px;
|
padding: 8px 10px 8px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav::-webkit-scrollbar {
|
.sidebar-nav::-webkit-scrollbar,
|
||||||
|
.sidebar--collapsed .sidebar-nav::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-group,
|
||||||
|
.nav-group__items,
|
||||||
|
.sidebar--collapsed .nav-group,
|
||||||
|
.sidebar--collapsed .nav-group__items {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-group {
|
.nav-group {
|
||||||
display: contents;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group__items {
|
.sidebar-nav .nav-group__label {
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group--collapsed .nav-group__items {
|
.nav-item,
|
||||||
display: contents;
|
.sidebar--collapsed .nav-item {
|
||||||
}
|
margin: 0;
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar--collapsed .nav-item--active::before,
|
||||||
|
.sidebar--collapsed .nav-item.active::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer,
|
||||||
|
.sidebar--collapsed .sidebar-footer {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,24 +151,17 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Nav */
|
.shell-nav {
|
||||||
.nav {
|
border-bottom-width: 0;
|
||||||
padding: 8px 10px;
|
|
||||||
gap: 4px;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav::-webkit-scrollbar {
|
.sidebar-header {
|
||||||
display: none;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group {
|
.sidebar-nav {
|
||||||
display: contents;
|
gap: 6px;
|
||||||
}
|
padding: 6px 8px 6px 0;
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
@@ -239,6 +289,26 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__input {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__toolbar {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn,
|
||||||
|
.agent-chat__toolbar .btn-ghost {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__input-btn svg,
|
||||||
|
.agent-chat__toolbar .btn-ghost svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Log stream */
|
/* Log stream */
|
||||||
.log-stream {
|
.log-stream {
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -288,16 +358,10 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme toggle */
|
.theme-orb__trigger {
|
||||||
.theme-toggle {
|
width: 26px;
|
||||||
--theme-item: 24px;
|
height: 26px;
|
||||||
--theme-gap: 2px;
|
font-size: 13px;
|
||||||
--theme-pad: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-icon {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,10 +379,6 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
|
||||||
padding: 6px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -361,14 +421,9 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-orb__trigger {
|
||||||
--theme-item: 22px;
|
width: 24px;
|
||||||
--theme-gap: 2px;
|
height: 24px;
|
||||||
--theme-pad: 2px;
|
font-size: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.theme-icon {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,33 @@ import { scheduleChatScroll } from "./app-scroll.ts";
|
|||||||
import { setLastActiveSessionKey } from "./app-settings.ts";
|
import { setLastActiveSessionKey } from "./app-settings.ts";
|
||||||
import { resetToolStream } from "./app-tool-stream.ts";
|
import { resetToolStream } from "./app-tool-stream.ts";
|
||||||
import type { OpenClawApp } from "./app.ts";
|
import type { OpenClawApp } from "./app.ts";
|
||||||
|
import { executeSlashCommand } from "./chat/slash-command-executor.ts";
|
||||||
|
import { parseSlashCommand } from "./chat/slash-commands.ts";
|
||||||
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
|
import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat.ts";
|
||||||
import { loadSessions } from "./controllers/sessions.ts";
|
import { loadSessions } from "./controllers/sessions.ts";
|
||||||
import type { GatewayHelloOk } from "./gateway.ts";
|
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||||
import { normalizeBasePath } from "./navigation.ts";
|
import { normalizeBasePath } from "./navigation.ts";
|
||||||
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
|
||||||
import { generateUUID } from "./uuid.ts";
|
import { generateUUID } from "./uuid.ts";
|
||||||
|
|
||||||
export type ChatHost = {
|
export type ChatHost = {
|
||||||
|
client: GatewayBrowserClient | null;
|
||||||
|
chatMessages: unknown[];
|
||||||
|
chatStream: string | null;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
chatAttachments: ChatAttachment[];
|
chatAttachments: ChatAttachment[];
|
||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
chatSending: boolean;
|
chatSending: boolean;
|
||||||
|
lastError?: string | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
hello: GatewayHelloOk | null;
|
hello: GatewayHelloOk | null;
|
||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
refreshSessionsAfterChat: Set<string>;
|
refreshSessionsAfterChat: Set<string>;
|
||||||
|
/** Callback for slash-command side effects that need app-level access. */
|
||||||
|
onSlashAction?: (action: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
|
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
|
||||||
@@ -73,6 +81,7 @@ function enqueueChatMessage(
|
|||||||
text: string,
|
text: string,
|
||||||
attachments?: ChatAttachment[],
|
attachments?: ChatAttachment[],
|
||||||
refreshSessions?: boolean,
|
refreshSessions?: boolean,
|
||||||
|
localCommand?: { args: string; name: string },
|
||||||
) {
|
) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||||
@@ -87,6 +96,8 @@ function enqueueChatMessage(
|
|||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
localCommandArgs: localCommand?.args,
|
||||||
|
localCommandName: localCommand?.name,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -143,12 +154,25 @@ async function flushChatQueue(host: ChatHost) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.chatQueue = rest;
|
host.chatQueue = rest;
|
||||||
const ok = await sendChatMessageNow(host, next.text, {
|
let ok = false;
|
||||||
|
try {
|
||||||
|
if (next.localCommandName) {
|
||||||
|
await dispatchSlashCommand(host, next.localCommandName, next.localCommandArgs ?? "");
|
||||||
|
ok = true;
|
||||||
|
} else {
|
||||||
|
ok = await sendChatMessageNow(host, next.text, {
|
||||||
attachments: next.attachments,
|
attachments: next.attachments,
|
||||||
refreshSessions: next.refreshSessions,
|
refreshSessions: next.refreshSessions,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
host.lastError = String(err);
|
||||||
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
host.chatQueue = [next, ...host.chatQueue];
|
host.chatQueue = [next, ...host.chatQueue];
|
||||||
|
} else if (host.chatQueue.length > 0) {
|
||||||
|
// Continue draining — local commands don't block on server response
|
||||||
|
void flushChatQueue(host);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +194,6 @@ export async function handleSendChat(
|
|||||||
const attachmentsToSend = messageOverride == null ? attachments : [];
|
const attachmentsToSend = messageOverride == null ? attachments : [];
|
||||||
const hasAttachments = attachmentsToSend.length > 0;
|
const hasAttachments = attachmentsToSend.length > 0;
|
||||||
|
|
||||||
// Allow sending with just attachments (no message text required)
|
|
||||||
if (!message && !hasAttachments) {
|
if (!message && !hasAttachments) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -180,10 +203,35 @@ export async function handleSendChat(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Intercept local slash commands (/status, /model, /compact, etc.)
|
||||||
|
const parsed = parseSlashCommand(message);
|
||||||
|
if (parsed?.command.executeLocal) {
|
||||||
|
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.name)) {
|
||||||
|
if (messageOverride == null) {
|
||||||
|
host.chatMessage = "";
|
||||||
|
host.chatAttachments = [];
|
||||||
|
}
|
||||||
|
enqueueChatMessage(host, message, undefined, isChatResetCommand(message), {
|
||||||
|
args: parsed.args,
|
||||||
|
name: parsed.command.name,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prevDraft = messageOverride == null ? previousDraft : undefined;
|
||||||
|
if (messageOverride == null) {
|
||||||
|
host.chatMessage = "";
|
||||||
|
host.chatAttachments = [];
|
||||||
|
}
|
||||||
|
await dispatchSlashCommand(host, parsed.command.name, parsed.args, {
|
||||||
|
previousDraft: prevDraft,
|
||||||
|
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const refreshSessions = isChatResetCommand(message);
|
const refreshSessions = isChatResetCommand(message);
|
||||||
if (messageOverride == null) {
|
if (messageOverride == null) {
|
||||||
host.chatMessage = "";
|
host.chatMessage = "";
|
||||||
// Clear attachments when sending
|
|
||||||
host.chatAttachments = [];
|
host.chatAttachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +250,99 @@ export async function handleSendChat(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldQueueLocalSlashCommand(name: string): boolean {
|
||||||
|
return !["stop", "focus", "export"].includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Slash Command Dispatch ──
|
||||||
|
|
||||||
|
async function dispatchSlashCommand(
|
||||||
|
host: ChatHost,
|
||||||
|
name: string,
|
||||||
|
args: string,
|
||||||
|
sendOpts?: { previousDraft?: string; restoreDraft?: boolean },
|
||||||
|
) {
|
||||||
|
switch (name) {
|
||||||
|
case "stop":
|
||||||
|
await handleAbortChat(host);
|
||||||
|
return;
|
||||||
|
case "new":
|
||||||
|
await sendChatMessageNow(host, "/new", {
|
||||||
|
refreshSessions: true,
|
||||||
|
previousDraft: sendOpts?.previousDraft,
|
||||||
|
restoreDraft: sendOpts?.restoreDraft,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "reset":
|
||||||
|
await sendChatMessageNow(host, "/reset", {
|
||||||
|
refreshSessions: true,
|
||||||
|
previousDraft: sendOpts?.previousDraft,
|
||||||
|
restoreDraft: sendOpts?.restoreDraft,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "clear":
|
||||||
|
await clearChatHistory(host);
|
||||||
|
return;
|
||||||
|
case "focus":
|
||||||
|
host.onSlashAction?.("toggle-focus");
|
||||||
|
return;
|
||||||
|
case "export":
|
||||||
|
host.onSlashAction?.("export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!host.client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await executeSlashCommand(host.client, host.sessionKey, name, args);
|
||||||
|
|
||||||
|
if (result.content) {
|
||||||
|
injectCommandResult(host, result.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.action === "refresh") {
|
||||||
|
await refreshChat(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearChatHistory(host: ChatHost) {
|
||||||
|
if (!host.client || !host.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await host.client.request("sessions.reset", { key: host.sessionKey });
|
||||||
|
host.chatMessages = [];
|
||||||
|
host.chatStream = null;
|
||||||
|
host.chatRunId = null;
|
||||||
|
await loadChatHistory(host as unknown as OpenClawApp);
|
||||||
|
} catch (err) {
|
||||||
|
host.lastError = String(err);
|
||||||
|
}
|
||||||
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCommandResult(host: ChatHost, content: string) {
|
||||||
|
host.chatMessages = [
|
||||||
|
...host.chatMessages,
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
|
export async function refreshChat(host: ChatHost, opts?: { scheduleScroll?: boolean }) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadChatHistory(host as unknown as OpenClawApp),
|
loadChatHistory(host as unknown as OpenClawApp),
|
||||||
loadSessions(host as unknown as OpenClawApp, {
|
loadSessions(host as unknown as OpenClawApp, {
|
||||||
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
activeMinutes: 0,
|
||||||
|
limit: 0,
|
||||||
|
includeGlobal: false,
|
||||||
|
includeUnknown: false,
|
||||||
}),
|
}),
|
||||||
refreshChatAvatar(host),
|
refreshChatAvatar(host),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
|
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream.ts";
|
||||||
import type { OpenClawApp } from "./app.ts";
|
import type { OpenClawApp } from "./app.ts";
|
||||||
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
|
import { shouldReloadHistoryForFinalEvent } from "./chat-event-reload.ts";
|
||||||
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
|
import { loadAgents } from "./controllers/agents.ts";
|
||||||
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
|
import { loadAssistantIdentity } from "./controllers/assistant-identity.ts";
|
||||||
import { loadChatHistory } from "./controllers/chat.ts";
|
import { loadChatHistory } from "./controllers/chat.ts";
|
||||||
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
|
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat.ts";
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
parseExecApprovalResolved,
|
parseExecApprovalResolved,
|
||||||
removeExecApproval,
|
removeExecApproval,
|
||||||
} from "./controllers/exec-approval.ts";
|
} from "./controllers/exec-approval.ts";
|
||||||
|
import { loadHealthState } from "./controllers/health.ts";
|
||||||
import { loadNodes } from "./controllers/nodes.ts";
|
import { loadNodes } from "./controllers/nodes.ts";
|
||||||
import { loadSessions } from "./controllers/sessions.ts";
|
import { loadSessions } from "./controllers/sessions.ts";
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +40,7 @@ import type { UiSettings } from "./storage.ts";
|
|||||||
import type {
|
import type {
|
||||||
AgentsListResult,
|
AgentsListResult,
|
||||||
PresenceEntry,
|
PresenceEntry,
|
||||||
HealthSnapshot,
|
HealthSummary,
|
||||||
StatusSummary,
|
StatusSummary,
|
||||||
UpdateAvailable,
|
UpdateAvailable,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
@@ -81,10 +82,10 @@ type GatewayHost = {
|
|||||||
agentsLoading: boolean;
|
agentsLoading: boolean;
|
||||||
agentsList: AgentsListResult | null;
|
agentsList: AgentsListResult | null;
|
||||||
agentsError: string | null;
|
agentsError: string | null;
|
||||||
toolsCatalogLoading: boolean;
|
healthLoading: boolean;
|
||||||
toolsCatalogError: string | null;
|
healthResult: HealthSummary | null;
|
||||||
toolsCatalogResult: import("./types.ts").ToolsCatalogResult | null;
|
healthError: string | null;
|
||||||
debugHealth: HealthSnapshot | null;
|
debugHealth: HealthSummary | null;
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
assistantAvatar: string | null;
|
assistantAvatar: string | null;
|
||||||
assistantAgentId: string | null;
|
assistantAgentId: string | null;
|
||||||
@@ -221,7 +222,7 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||||
void loadAssistantIdentity(host as unknown as OpenClawApp);
|
void loadAssistantIdentity(host as unknown as OpenClawApp);
|
||||||
void loadAgents(host as unknown as OpenClawApp);
|
void loadAgents(host as unknown as OpenClawApp);
|
||||||
void loadToolsCatalog(host as unknown as OpenClawApp);
|
void loadHealthState(host as unknown as OpenClawApp);
|
||||||
void loadNodes(host as unknown as OpenClawApp, { quiet: true });
|
void loadNodes(host as unknown as OpenClawApp, { quiet: true });
|
||||||
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
void loadDevices(host as unknown as OpenClawApp, { quiet: true });
|
||||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||||
@@ -326,7 +327,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
|||||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||||
...host.eventLogBuffer,
|
...host.eventLogBuffer,
|
||||||
].slice(0, 250);
|
].slice(0, 250);
|
||||||
if (host.tab === "debug") {
|
if (host.tab === "debug" || host.tab === "overview") {
|
||||||
host.eventLog = host.eventLogBuffer;
|
host.eventLog = host.eventLogBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +407,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
|||||||
const snapshot = hello.snapshot as
|
const snapshot = hello.snapshot as
|
||||||
| {
|
| {
|
||||||
presence?: PresenceEntry[];
|
presence?: PresenceEntry[];
|
||||||
health?: HealthSnapshot;
|
health?: HealthSummary;
|
||||||
sessionDefaults?: SessionDefaultsSnapshot;
|
sessionDefaults?: SessionDefaultsSnapshot;
|
||||||
updateAvailable?: UpdateAvailable;
|
updateAvailable?: UpdateAvailable;
|
||||||
}
|
}
|
||||||
@@ -416,6 +417,7 @@ export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
|||||||
}
|
}
|
||||||
if (snapshot?.health) {
|
if (snapshot?.health) {
|
||||||
host.debugHealth = snapshot.health;
|
host.debugHealth = snapshot.health;
|
||||||
|
host.healthResult = snapshot.health;
|
||||||
}
|
}
|
||||||
if (snapshot?.sessionDefaults) {
|
if (snapshot?.sessionDefaults) {
|
||||||
applySessionDefaults(host, snapshot.sessionDefaults);
|
applySessionDefaults(host, snapshot.sessionDefaults);
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { html } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { repeat } from "lit/directives/repeat.js";
|
import { repeat } from "lit/directives/repeat.js";
|
||||||
|
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
|
||||||
import { t } from "../i18n/index.ts";
|
import { t } from "../i18n/index.ts";
|
||||||
import { refreshChat } from "./app-chat.ts";
|
import { refreshChat } from "./app-chat.ts";
|
||||||
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
import { syncUrlWithSessionKey } from "./app-settings.ts";
|
||||||
import type { AppViewState } from "./app-view-state.ts";
|
import type { AppViewState } from "./app-view-state.ts";
|
||||||
import { OpenClawApp } from "./app.ts";
|
import { OpenClawApp } from "./app.ts";
|
||||||
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
import { ChatState, loadChatHistory } from "./controllers/chat.ts";
|
||||||
|
import { loadSessions } from "./controllers/sessions.ts";
|
||||||
import { icons } from "./icons.ts";
|
import { icons } from "./icons.ts";
|
||||||
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
|
import { iconForTab, pathForTab, titleForTab, type Tab } from "./navigation.ts";
|
||||||
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
import type { ThemeTransitionContext } from "./theme-transition.ts";
|
||||||
import type { ThemeMode } from "./theme.ts";
|
import type { ThemeMode, ThemeName } from "./theme.ts";
|
||||||
import type { SessionsListResult } from "./types.ts";
|
import type { SessionsListResult } from "./types.ts";
|
||||||
|
|
||||||
type SessionDefaultsSnapshot = {
|
type SessionDefaultsSnapshot = {
|
||||||
@@ -49,10 +51,12 @@ function resetChatStateForSessionSwitch(state: AppViewState, sessionKey: string)
|
|||||||
|
|
||||||
export function renderTab(state: AppViewState, tab: Tab) {
|
export function renderTab(state: AppViewState, tab: Tab) {
|
||||||
const href = pathForTab(tab, state.basePath);
|
const href = pathForTab(tab, state.basePath);
|
||||||
|
const isActive = state.tab === tab;
|
||||||
|
const collapsed = state.settings.navCollapsed;
|
||||||
return html`
|
return html`
|
||||||
<a
|
<a
|
||||||
href=${href}
|
href=${href}
|
||||||
class="nav-item ${state.tab === tab ? "active" : ""}"
|
class="nav-item ${isActive ? "nav-item--active" : ""}"
|
||||||
@click=${(event: MouseEvent) => {
|
@click=${(event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
event.defaultPrevented ||
|
event.defaultPrevented ||
|
||||||
@@ -77,7 +81,7 @@ export function renderTab(state: AppViewState, tab: Tab) {
|
|||||||
title=${titleForTab(tab)}
|
title=${titleForTab(tab)}
|
||||||
>
|
>
|
||||||
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
<span class="nav-item__icon" aria-hidden="true">${icons[iconForTab(tab)]}</span>
|
||||||
<span class="nav-item__text">${titleForTab(tab)}</span>
|
${!collapsed ? html`<span class="nav-item__text">${titleForTab(tab)}</span>` : nothing}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -122,23 +126,52 @@ function renderCronFilterIcon(hiddenCount: number) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renderChatSessionSelect(state: AppViewState) {
|
||||||
|
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
|
||||||
|
return html`
|
||||||
|
<div class="chat-controls__session-row">
|
||||||
|
<label class="field chat-controls__session">
|
||||||
|
<select
|
||||||
|
.value=${state.sessionKey}
|
||||||
|
?disabled=${!state.connected || sessionGroups.length === 0}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const next = (e.target as HTMLSelectElement).value;
|
||||||
|
if (state.sessionKey === next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switchChatSession(state, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${repeat(
|
||||||
|
sessionGroups,
|
||||||
|
(group) => group.id,
|
||||||
|
(group) =>
|
||||||
|
html`<optgroup label=${group.label}>
|
||||||
|
${repeat(
|
||||||
|
group.options,
|
||||||
|
(entry) => entry.key,
|
||||||
|
(entry) =>
|
||||||
|
html`<option value=${entry.key} title=${entry.title}>
|
||||||
|
${entry.label}
|
||||||
|
</option>`,
|
||||||
|
)}
|
||||||
|
</optgroup>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderChatControls(state: AppViewState) {
|
export function renderChatControls(state: AppViewState) {
|
||||||
const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult);
|
|
||||||
const hideCron = state.sessionsHideCron ?? true;
|
const hideCron = state.sessionsHideCron ?? true;
|
||||||
const hiddenCronCount = hideCron
|
const hiddenCronCount = hideCron
|
||||||
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
|
? countHiddenCronSessions(state.sessionKey, state.sessionsResult)
|
||||||
: 0;
|
: 0;
|
||||||
const sessionOptions = resolveSessionOptions(
|
|
||||||
state.sessionKey,
|
|
||||||
state.sessionsResult,
|
|
||||||
mainSessionKey,
|
|
||||||
hideCron,
|
|
||||||
);
|
|
||||||
const disableThinkingToggle = state.onboarding;
|
const disableThinkingToggle = state.onboarding;
|
||||||
const disableFocusToggle = state.onboarding;
|
const disableFocusToggle = state.onboarding;
|
||||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||||
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
|
||||||
// Refresh icon
|
|
||||||
const refreshIcon = html`
|
const refreshIcon = html`
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
@@ -174,43 +207,6 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
`;
|
`;
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-controls">
|
<div class="chat-controls">
|
||||||
<label class="field chat-controls__session">
|
|
||||||
<select
|
|
||||||
.value=${state.sessionKey}
|
|
||||||
?disabled=${!state.connected}
|
|
||||||
@change=${(e: Event) => {
|
|
||||||
const next = (e.target as HTMLSelectElement).value;
|
|
||||||
state.sessionKey = next;
|
|
||||||
state.chatMessage = "";
|
|
||||||
state.chatStream = null;
|
|
||||||
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
|
||||||
state.chatRunId = null;
|
|
||||||
(state as unknown as OpenClawApp).resetToolStream();
|
|
||||||
(state as unknown as OpenClawApp).resetChatScroll();
|
|
||||||
state.applySettings({
|
|
||||||
...state.settings,
|
|
||||||
sessionKey: next,
|
|
||||||
lastActiveSessionKey: next,
|
|
||||||
});
|
|
||||||
void state.loadAssistantIdentity();
|
|
||||||
syncUrlWithSessionKey(
|
|
||||||
state as unknown as Parameters<typeof syncUrlWithSessionKey>[0],
|
|
||||||
next,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
void loadChatHistory(state as unknown as ChatState);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
${repeat(
|
|
||||||
sessionOptions,
|
|
||||||
(entry) => entry.key,
|
|
||||||
(entry) =>
|
|
||||||
html`<option value=${entry.key} title=${entry.key}>
|
|
||||||
${entry.displayName ?? entry.key}
|
|
||||||
</option>`,
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm btn--icon"
|
class="btn btn--sm btn--icon"
|
||||||
?disabled=${state.chatLoading || !state.connected}
|
?disabled=${state.chatLoading || !state.connected}
|
||||||
@@ -291,23 +287,38 @@ export function renderChatControls(state: AppViewState) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMainSessionKey(
|
function switchChatSession(state: AppViewState, nextSessionKey: string) {
|
||||||
hello: AppViewState["hello"],
|
state.sessionKey = nextSessionKey;
|
||||||
sessions: SessionsListResult | null,
|
state.chatMessage = "";
|
||||||
): string | null {
|
state.chatStream = null;
|
||||||
const snapshot = hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
|
// P1: Clear queued chat items from the previous session
|
||||||
const mainSessionKey = snapshot?.sessionDefaults?.mainSessionKey?.trim();
|
(state as unknown as { chatQueue: unknown[] }).chatQueue = [];
|
||||||
if (mainSessionKey) {
|
(state as unknown as OpenClawApp).chatStreamStartedAt = null;
|
||||||
return mainSessionKey;
|
state.chatRunId = null;
|
||||||
|
(state as unknown as OpenClawApp).resetToolStream();
|
||||||
|
(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) {
|
async function refreshSessionOptions(state: AppViewState) {
|
||||||
return mainKey;
|
await loadSessions(state as unknown as Parameters<typeof loadSessions>[0], {
|
||||||
}
|
activeMinutes: 0,
|
||||||
if (sessions?.sessions?.some((row) => row.key === "main")) {
|
limit: 0,
|
||||||
return "main";
|
includeGlobal: false,
|
||||||
}
|
includeUnknown: false,
|
||||||
return null;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Channel display labels ────────────────────────────── */
|
/* ── Channel display labels ────────────────────────────── */
|
||||||
@@ -431,51 +442,75 @@ export function isCronSessionKey(key: string): boolean {
|
|||||||
return rest.startsWith("cron:");
|
return rest.startsWith("cron:");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSessionOptions(
|
type SessionOptionEntry = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionOptionGroup = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
options: SessionOptionEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveSessionOptionGroups(
|
||||||
|
state: AppViewState,
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
sessions: SessionsListResult | null,
|
sessions: SessionsListResult | null,
|
||||||
mainSessionKey?: string | null,
|
): SessionOptionGroup[] {
|
||||||
hideCron = false,
|
const rows = sessions?.sessions ?? [];
|
||||||
) {
|
const hideCron = state.sessionsHideCron ?? true;
|
||||||
const seen = new Set<string>();
|
const byKey = new Map<string, SessionsListResult["sessions"][number]>();
|
||||||
const options: Array<{ key: string; displayName?: string }> = [];
|
for (const row of rows) {
|
||||||
|
byKey.set(row.key, row);
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
|
const seenKeys = new Set<string>();
|
||||||
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
|
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
|
const addOption = (key: string) => {
|
||||||
if (mainSessionKey) {
|
if (!key || seenKeys.has(key)) {
|
||||||
seen.add(mainSessionKey);
|
return;
|
||||||
options.push({
|
}
|
||||||
key: mainSessionKey,
|
seenKeys.add(key);
|
||||||
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain || undefined),
|
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,
|
for (const row of rows) {
|
||||||
// so the active session is never silently dropped from the select.
|
if (hideCron && row.key !== sessionKey && isCronSessionKey(row.key)) {
|
||||||
if (!seen.has(sessionKey)) {
|
continue;
|
||||||
seen.add(sessionKey);
|
|
||||||
options.push({
|
|
||||||
key: sessionKey,
|
|
||||||
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
addOption(row.key);
|
||||||
// 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(sessionKey);
|
||||||
}
|
return Array.from(groups.values());
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
|
/** Count sessions with a cron: key that would be hidden when hideCron=true. */
|
||||||
@@ -487,88 +522,162 @@ function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResul
|
|||||||
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
|
return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];
|
function resolveAgentGroupLabel(state: AppViewState, agentIdRaw: string): string {
|
||||||
|
const normalized = agentIdRaw.trim().toLowerCase();
|
||||||
export function renderThemeToggle(state: AppViewState) {
|
const agent = (state.agentsList?.agents ?? []).find(
|
||||||
const index = Math.max(0, THEME_ORDER.indexOf(state.themeMode));
|
(entry) => entry.id.trim().toLowerCase() === normalized,
|
||||||
const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
|
);
|
||||||
const element = event.currentTarget as HTMLElement;
|
const name = agent?.identity?.name?.trim() || agent?.name?.trim() || "";
|
||||||
const context: ThemeTransitionContext = { element };
|
return name && name !== agentIdRaw ? `${name} (${agentIdRaw})` : agentIdRaw;
|
||||||
if (event.clientX || event.clientY) {
|
|
||||||
context.pointerClientX = event.clientX;
|
|
||||||
context.pointerClientY = event.clientY;
|
|
||||||
}
|
}
|
||||||
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`
|
return html`
|
||||||
<div class="theme-toggle" style="--theme-index: ${index};">
|
<div class="topbar-theme-mode" role="group" aria-label="Color mode">
|
||||||
<div class="theme-toggle__track" role="group" aria-label="Theme">
|
${THEME_MODE_OPTIONS.map(
|
||||||
<span class="theme-toggle__indicator"></span>
|
(opt) => html`
|
||||||
<button
|
<button
|
||||||
class="theme-toggle__button ${state.themeMode === "system" ? "active" : ""}"
|
type="button"
|
||||||
@click=${applyTheme("system")}
|
class="topbar-theme-mode__btn ${opt.id === state.themeMode ? "topbar-theme-mode__btn--active" : ""}"
|
||||||
aria-pressed=${state.themeMode === "system"}
|
title=${opt.label}
|
||||||
aria-label="System theme"
|
aria-label="Color mode: ${opt.label}"
|
||||||
title="System"
|
aria-pressed=${opt.id === state.themeMode}
|
||||||
|
@click=${(e: Event) => applyMode(opt.id, e)}
|
||||||
>
|
>
|
||||||
${renderMonitorIcon()}
|
${modeIcon(opt.id)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
`,
|
||||||
class="theme-toggle__button ${state.themeMode === "light" ? "active" : ""}"
|
)}
|
||||||
@click=${applyTheme("light")}
|
|
||||||
aria-pressed=${state.themeMode === "light"}
|
|
||||||
aria-label="Light theme"
|
|
||||||
title="Light"
|
|
||||||
>
|
|
||||||
${renderSunIcon()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="theme-toggle__button ${state.themeMode === "dark" ? "active" : ""}"
|
|
||||||
@click=${applyTheme("dark")}
|
|
||||||
aria-pressed=${state.themeMode === "dark"}
|
|
||||||
aria-label="Dark theme"
|
|
||||||
title="Dark"
|
|
||||||
>
|
|
||||||
${renderMoonIcon()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSunIcon() {
|
export function renderThemeToggle(state: AppViewState) {
|
||||||
return html`
|
const setOpen = (orb: HTMLElement, nextOpen: boolean) => {
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
orb.classList.toggle("theme-orb--open", nextOpen);
|
||||||
<circle cx="12" cy="12" r="4"></circle>
|
const trigger = orb.querySelector<HTMLButtonElement>(".theme-orb__trigger");
|
||||||
<path d="M12 2v2"></path>
|
const menu = orb.querySelector<HTMLElement>(".theme-orb__menu");
|
||||||
<path d="M12 20v2"></path>
|
if (trigger) {
|
||||||
<path d="m4.93 4.93 1.41 1.41"></path>
|
trigger.setAttribute("aria-expanded", nextOpen ? "true" : "false");
|
||||||
<path d="m17.66 17.66 1.41 1.41"></path>
|
|
||||||
<path d="M2 12h2"></path>
|
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
if (menu) {
|
||||||
|
menu.setAttribute("aria-hidden", nextOpen ? "false" : "true");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function renderMoonIcon() {
|
const toggleOpen = (e: Event) => {
|
||||||
return html`
|
const orb = (e.currentTarget as HTMLElement).closest<HTMLElement>(".theme-orb");
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
if (!orb) {
|
||||||
<path
|
return;
|
||||||
d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
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`
|
return html`
|
||||||
<svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
|
<div class="theme-orb" aria-label="Theme">
|
||||||
<rect width="20" height="14" x="2" y="3" rx="2"></rect>
|
<button
|
||||||
<line x1="8" x2="16" y1="21" y2="21"></line>
|
type="button"
|
||||||
<line x1="12" x2="12" y1="17" y2="21"></line>
|
class="theme-orb__trigger"
|
||||||
</svg>
|
title="Theme"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
@click=${toggleOpen}
|
||||||
|
>${currentThemeIcon(state.theme)}</button>
|
||||||
|
<div class="theme-orb__menu" role="menu" aria-hidden="true">
|
||||||
|
${THEME_OPTIONS.map(
|
||||||
|
(opt) => html`
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-orb__option ${opt.id === state.theme ? "theme-orb__option--active" : ""}"
|
||||||
|
title=${opt.label}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked=${opt.id === state.theme}
|
||||||
|
aria-label=${opt.label}
|
||||||
|
@click=${(e: Event) => pick(opt, e)}
|
||||||
|
>${opt.icon}</button>`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
import { roleScopesAllow } from "../../../src/shared/operator-scope-compat.js";
|
||||||
import { refreshChat } from "./app-chat.ts";
|
import { refreshChat } from "./app-chat.ts";
|
||||||
import {
|
import {
|
||||||
startLogsPolling,
|
startLogsPolling,
|
||||||
@@ -9,15 +10,10 @@ import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll.ts";
|
|||||||
import type { OpenClawApp } from "./app.ts";
|
import type { OpenClawApp } from "./app.ts";
|
||||||
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
import { loadAgentIdentities, loadAgentIdentity } from "./controllers/agent-identity.ts";
|
||||||
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
import { loadAgentSkills } from "./controllers/agent-skills.ts";
|
||||||
import { loadAgents, loadToolsCatalog } from "./controllers/agents.ts";
|
import { loadAgents } from "./controllers/agents.ts";
|
||||||
import { loadChannels } from "./controllers/channels.ts";
|
import { loadChannels } from "./controllers/channels.ts";
|
||||||
import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
import { loadConfig, loadConfigSchema } from "./controllers/config.ts";
|
||||||
import {
|
import { loadCronJobs, loadCronRuns, loadCronStatus } from "./controllers/cron.ts";
|
||||||
loadCronJobs,
|
|
||||||
loadCronModelSuggestions,
|
|
||||||
loadCronRuns,
|
|
||||||
loadCronStatus,
|
|
||||||
} from "./controllers/cron.ts";
|
|
||||||
import { loadDebug } from "./controllers/debug.ts";
|
import { loadDebug } from "./controllers/debug.ts";
|
||||||
import { loadDevices } from "./controllers/devices.ts";
|
import { loadDevices } from "./controllers/devices.ts";
|
||||||
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
|
import { loadExecApprovals } from "./controllers/exec-approvals.ts";
|
||||||
@@ -26,6 +22,7 @@ import { loadNodes } from "./controllers/nodes.ts";
|
|||||||
import { loadPresence } from "./controllers/presence.ts";
|
import { loadPresence } from "./controllers/presence.ts";
|
||||||
import { loadSessions } from "./controllers/sessions.ts";
|
import { loadSessions } from "./controllers/sessions.ts";
|
||||||
import { loadSkills } from "./controllers/skills.ts";
|
import { loadSkills } from "./controllers/skills.ts";
|
||||||
|
import { loadUsage } from "./controllers/usage.ts";
|
||||||
import {
|
import {
|
||||||
inferBasePathFromPathname,
|
inferBasePathFromPathname,
|
||||||
normalizeBasePath,
|
normalizeBasePath,
|
||||||
@@ -36,15 +33,9 @@ import {
|
|||||||
} from "./navigation.ts";
|
} from "./navigation.ts";
|
||||||
import { saveSettings, type UiSettings } from "./storage.ts";
|
import { saveSettings, type UiSettings } from "./storage.ts";
|
||||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
|
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition.ts";
|
||||||
import {
|
import { resolveTheme, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||||
colorSchemeForTheme,
|
import type { AgentsListResult, AttentionItem } from "./types.ts";
|
||||||
dataThemeForTheme,
|
import { resetChatViewState } from "./views/chat.ts";
|
||||||
resolveTheme,
|
|
||||||
type ResolvedTheme,
|
|
||||||
type ThemeMode,
|
|
||||||
type ThemeName,
|
|
||||||
} from "./theme.ts";
|
|
||||||
import type { AgentsListResult } from "./types.ts";
|
|
||||||
|
|
||||||
type SettingsHost = {
|
type SettingsHost = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
@@ -64,9 +55,8 @@ type SettingsHost = {
|
|||||||
agentsList?: AgentsListResult | null;
|
agentsList?: AgentsListResult | null;
|
||||||
agentsSelectedId?: string | null;
|
agentsSelectedId?: string | null;
|
||||||
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
agentsPanel?: "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||||
themeMedia: MediaQueryList | null;
|
|
||||||
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
|
|
||||||
pendingGatewayUrl?: string | null;
|
pendingGatewayUrl?: string | null;
|
||||||
|
systemThemeCleanup?: (() => void) | null;
|
||||||
pendingGatewayToken?: string | null;
|
pendingGatewayToken?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,17 +166,17 @@ export function setTab(host: SettingsHost, next: Tab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
|
export function setTheme(host: SettingsHost, next: ThemeName, context?: ThemeTransitionContext) {
|
||||||
|
const resolved = resolveTheme(next, host.themeMode);
|
||||||
const applyTheme = () => {
|
const applyTheme = () => {
|
||||||
host.theme = next;
|
|
||||||
applySettings(host, { ...host.settings, theme: next });
|
applySettings(host, { ...host.settings, theme: next });
|
||||||
applyResolvedTheme(host, resolveTheme(next, host.themeMode));
|
|
||||||
};
|
};
|
||||||
startThemeTransition({
|
startThemeTransition({
|
||||||
nextTheme: resolveTheme(next, host.themeMode),
|
nextTheme: resolved,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
context,
|
context,
|
||||||
currentTheme: host.themeResolved,
|
currentTheme: host.themeResolved,
|
||||||
});
|
});
|
||||||
|
syncSystemThemeListener(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setThemeMode(
|
export function setThemeMode(
|
||||||
@@ -194,17 +184,17 @@ export function setThemeMode(
|
|||||||
next: ThemeMode,
|
next: ThemeMode,
|
||||||
context?: ThemeTransitionContext,
|
context?: ThemeTransitionContext,
|
||||||
) {
|
) {
|
||||||
const applyTheme = () => {
|
const resolved = resolveTheme(host.theme, next);
|
||||||
host.themeMode = next;
|
const applyMode = () => {
|
||||||
applySettings(host, { ...host.settings, themeMode: next });
|
applySettings(host, { ...host.settings, themeMode: next });
|
||||||
applyResolvedTheme(host, resolveTheme(host.theme, next));
|
|
||||||
};
|
};
|
||||||
startThemeTransition({
|
startThemeTransition({
|
||||||
nextTheme: resolveTheme(host.theme, next),
|
nextTheme: resolved,
|
||||||
applyTheme,
|
applyTheme: applyMode,
|
||||||
context,
|
context,
|
||||||
currentTheme: host.themeResolved,
|
currentTheme: host.themeResolved,
|
||||||
});
|
});
|
||||||
|
syncSystemThemeListener(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshActiveTab(host: SettingsHost) {
|
export async function refreshActiveTab(host: SettingsHost) {
|
||||||
@@ -228,7 +218,6 @@ export async function refreshActiveTab(host: SettingsHost) {
|
|||||||
}
|
}
|
||||||
if (host.tab === "agents") {
|
if (host.tab === "agents") {
|
||||||
await loadAgents(host as unknown as OpenClawApp);
|
await loadAgents(host as unknown as OpenClawApp);
|
||||||
await loadToolsCatalog(host as unknown as OpenClawApp);
|
|
||||||
await loadConfig(host as unknown as OpenClawApp);
|
await loadConfig(host as unknown as OpenClawApp);
|
||||||
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
const agentIds = host.agentsList?.agents?.map((entry) => entry.id) ?? [];
|
||||||
if (agentIds.length > 0) {
|
if (agentIds.length > 0) {
|
||||||
@@ -262,7 +251,14 @@ export async function refreshActiveTab(host: SettingsHost) {
|
|||||||
!host.chatHasAutoScrolled,
|
!host.chatHasAutoScrolled,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (host.tab === "config") {
|
if (
|
||||||
|
host.tab === "config" ||
|
||||||
|
host.tab === "communications" ||
|
||||||
|
host.tab === "appearance" ||
|
||||||
|
host.tab === "automation" ||
|
||||||
|
host.tab === "infrastructure" ||
|
||||||
|
host.tab === "aiAgents"
|
||||||
|
) {
|
||||||
await loadConfigSchema(host as unknown as OpenClawApp);
|
await loadConfigSchema(host as unknown as OpenClawApp);
|
||||||
await loadConfig(host as unknown as OpenClawApp);
|
await loadConfig(host as unknown as OpenClawApp);
|
||||||
}
|
}
|
||||||
@@ -289,9 +285,19 @@ export function inferBasePath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function syncThemeWithSettings(host: SettingsHost) {
|
export function syncThemeWithSettings(host: SettingsHost) {
|
||||||
host.theme = host.settings.theme;
|
host.theme = host.settings.theme ?? "claw";
|
||||||
host.themeMode = host.settings.themeMode;
|
host.themeMode = host.settings.themeMode ?? "system";
|
||||||
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
|
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
|
||||||
|
syncSystemThemeListener(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachThemeListener(host: SettingsHost) {
|
||||||
|
syncSystemThemeListener(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detachThemeListener(host: SettingsHost) {
|
||||||
|
host.systemThemeCleanup?.();
|
||||||
|
host.systemThemeCleanup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
|
||||||
@@ -300,45 +306,45 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
root.dataset.theme = dataThemeForTheme(resolved);
|
const themeMode = resolved.endsWith("light") ? "light" : "dark";
|
||||||
root.style.colorScheme = colorSchemeForTheme(resolved);
|
root.dataset.theme = resolved;
|
||||||
|
root.dataset.themeMode = themeMode;
|
||||||
|
root.style.colorScheme = themeMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachThemeListener(host: SettingsHost) {
|
function syncSystemThemeListener(host: SettingsHost) {
|
||||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
// Clean up existing listener if mode is not "system"
|
||||||
|
if (host.themeMode !== "system") {
|
||||||
|
host.systemThemeCleanup?.();
|
||||||
|
host.systemThemeCleanup = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
|
|
||||||
host.themeMediaHandler = (event) => {
|
// Skip if listener already attached for this host
|
||||||
|
if (host.systemThemeCleanup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof globalThis.matchMedia !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mql = globalThis.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
const onChange = () => {
|
||||||
if (host.themeMode !== "system") {
|
if (host.themeMode !== "system") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyResolvedTheme(host, resolveTheme(host.theme, event.matches ? "dark" : "light"));
|
applyResolvedTheme(host, resolveTheme(host.theme, "system"));
|
||||||
};
|
};
|
||||||
if (typeof host.themeMedia.addEventListener === "function") {
|
if (typeof mql.addEventListener === "function") {
|
||||||
host.themeMedia.addEventListener("change", host.themeMediaHandler);
|
mql.addEventListener("change", onChange);
|
||||||
|
host.systemThemeCleanup = () => mql.removeEventListener("change", onChange);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const legacy = host.themeMedia as MediaQueryList & {
|
if (typeof mql.addListener === "function") {
|
||||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
mql.addListener(onChange);
|
||||||
};
|
host.systemThemeCleanup = () => mql.removeListener(onChange);
|
||||||
legacy.addListener(host.themeMediaHandler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detachThemeListener(host: SettingsHost) {
|
|
||||||
if (!host.themeMedia || !host.themeMediaHandler) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof host.themeMedia.removeEventListener === "function") {
|
|
||||||
host.themeMedia.removeEventListener("change", host.themeMediaHandler);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const legacy = host.themeMedia as MediaQueryList & {
|
|
||||||
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
|
||||||
};
|
|
||||||
legacy.removeListener(host.themeMediaHandler);
|
|
||||||
host.themeMedia = null;
|
|
||||||
host.themeMediaHandler = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
export function syncTabWithLocation(host: SettingsHost, replace: boolean) {
|
||||||
@@ -382,9 +388,16 @@ function applyTabSelection(
|
|||||||
next: Tab,
|
next: Tab,
|
||||||
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
|
options: { refreshPolicy: "always" | "connected"; syncUrl?: boolean },
|
||||||
) {
|
) {
|
||||||
|
const prev = host.tab;
|
||||||
if (host.tab !== next) {
|
if (host.tab !== next) {
|
||||||
host.tab = next;
|
host.tab = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup chat module state when navigating away from chat
|
||||||
|
if (prev === "chat" && next !== "chat") {
|
||||||
|
resetChatViewState();
|
||||||
|
}
|
||||||
|
|
||||||
if (next === "chat") {
|
if (next === "chat") {
|
||||||
host.chatHasAutoScrolled = false;
|
host.chatHasAutoScrolled = false;
|
||||||
}
|
}
|
||||||
@@ -447,13 +460,143 @@ export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, re
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOverview(host: SettingsHost) {
|
export async function loadOverview(host: SettingsHost) {
|
||||||
await Promise.all([
|
const app = host as unknown as OpenClawApp;
|
||||||
loadChannels(host as unknown as OpenClawApp, false),
|
await Promise.allSettled([
|
||||||
loadPresence(host as unknown as OpenClawApp),
|
loadChannels(app, false),
|
||||||
loadSessions(host as unknown as OpenClawApp),
|
loadPresence(app),
|
||||||
loadCronStatus(host as unknown as OpenClawApp),
|
loadSessions(app),
|
||||||
loadDebug(host as unknown as OpenClawApp),
|
loadCronStatus(app),
|
||||||
|
loadCronJobs(app),
|
||||||
|
loadDebug(app),
|
||||||
|
loadSkills(app),
|
||||||
|
loadUsage(app),
|
||||||
|
loadOverviewLogs(app),
|
||||||
]);
|
]);
|
||||||
|
buildAttentionItems(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOperatorReadAccess(
|
||||||
|
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||||
|
): boolean {
|
||||||
|
if (!auth?.scopes) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return roleScopesAllow({
|
||||||
|
role: auth.role ?? "operator",
|
||||||
|
requestedScopes: ["operator.read"],
|
||||||
|
allowedScopes: auth.scopes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMissingSkillDependencies(
|
||||||
|
missing: Record<string, unknown> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!missing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.values(missing).some((value) => Array.isArray(value) && value.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverviewLogs(host: OpenClawApp) {
|
||||||
|
if (!host.client || !host.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await host.client.request("logs.tail", {
|
||||||
|
cursor: host.overviewLogCursor || undefined,
|
||||||
|
limit: 100,
|
||||||
|
maxBytes: 50_000,
|
||||||
|
});
|
||||||
|
const payload = res as {
|
||||||
|
cursor?: number;
|
||||||
|
lines?: unknown;
|
||||||
|
};
|
||||||
|
const lines = Array.isArray(payload.lines)
|
||||||
|
? payload.lines.filter((line): line is string => typeof line === "string")
|
||||||
|
: [];
|
||||||
|
host.overviewLogLines = [...host.overviewLogLines, ...lines].slice(-500);
|
||||||
|
if (typeof payload.cursor === "number") {
|
||||||
|
host.overviewLogCursor = payload.cursor;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttentionItems(host: OpenClawApp) {
|
||||||
|
const items: AttentionItem[] = [];
|
||||||
|
|
||||||
|
if (host.lastError) {
|
||||||
|
items.push({
|
||||||
|
severity: "error",
|
||||||
|
icon: "x",
|
||||||
|
title: "Gateway Error",
|
||||||
|
description: host.lastError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hello = host.hello;
|
||||||
|
const auth = (hello as { auth?: { role?: string; scopes?: string[] } } | null)?.auth ?? null;
|
||||||
|
if (auth?.scopes && !hasOperatorReadAccess(auth)) {
|
||||||
|
items.push({
|
||||||
|
severity: "warning",
|
||||||
|
icon: "key",
|
||||||
|
title: "Missing operator.read scope",
|
||||||
|
description:
|
||||||
|
"This connection does not have the operator.read scope. Some features may be unavailable.",
|
||||||
|
href: "https://docs.openclaw.ai/web/dashboard",
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const skills = host.skillsReport?.skills ?? [];
|
||||||
|
const missingDeps = skills.filter((s) => !s.disabled && hasMissingSkillDependencies(s.missing));
|
||||||
|
if (missingDeps.length > 0) {
|
||||||
|
const names = missingDeps.slice(0, 3).map((s) => s.name);
|
||||||
|
const more = missingDeps.length > 3 ? ` +${missingDeps.length - 3} more` : "";
|
||||||
|
items.push({
|
||||||
|
severity: "warning",
|
||||||
|
icon: "zap",
|
||||||
|
title: "Skills with missing dependencies",
|
||||||
|
description: `${names.join(", ")}${more}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = skills.filter((s) => s.blockedByAllowlist);
|
||||||
|
if (blocked.length > 0) {
|
||||||
|
items.push({
|
||||||
|
severity: "warning",
|
||||||
|
icon: "shield",
|
||||||
|
title: `${blocked.length} skill${blocked.length > 1 ? "s" : ""} blocked`,
|
||||||
|
description: blocked.map((s) => s.name).join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cronJobs = host.cronJobs ?? [];
|
||||||
|
const failedCron = cronJobs.filter((j) => j.state?.lastStatus === "error");
|
||||||
|
if (failedCron.length > 0) {
|
||||||
|
items.push({
|
||||||
|
severity: "error",
|
||||||
|
icon: "clock",
|
||||||
|
title: `${failedCron.length} cron job${failedCron.length > 1 ? "s" : ""} failed`,
|
||||||
|
description: failedCron.map((j) => j.name).join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const overdue = cronJobs.filter(
|
||||||
|
(j) => j.enabled && j.state?.nextRunAtMs != null && now - j.state.nextRunAtMs > 300_000,
|
||||||
|
);
|
||||||
|
if (overdue.length > 0) {
|
||||||
|
items.push({
|
||||||
|
severity: "warning",
|
||||||
|
icon: "clock",
|
||||||
|
title: `${overdue.length} overdue job${overdue.length > 1 ? "s" : ""}`,
|
||||||
|
description: overdue.map((j) => j.name).join(", "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
host.attentionItems = items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadChannelsTab(host: SettingsHost) {
|
export async function loadChannelsTab(host: SettingsHost) {
|
||||||
@@ -465,18 +608,12 @@ export async function loadChannelsTab(host: SettingsHost) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadCron(host: SettingsHost) {
|
export async function loadCron(host: SettingsHost) {
|
||||||
const cronHost = host as unknown as OpenClawApp;
|
const app = host as unknown as OpenClawApp;
|
||||||
|
const activeCronJobId = app.cronRunsScope === "job" ? app.cronRunsJobId : null;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadChannels(host as unknown as OpenClawApp, false),
|
loadChannels(app, false),
|
||||||
loadCronStatus(cronHost),
|
loadCronStatus(app),
|
||||||
loadCronJobs(cronHost),
|
loadCronJobs(app),
|
||||||
loadCronModelSuggestions(cronHost),
|
loadCronRuns(app, activeCronJobId),
|
||||||
]);
|
]);
|
||||||
if (cronHost.cronRunsScope === "all") {
|
|
||||||
await loadCronRuns(cronHost, null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cronHost.cronRunsJobId) {
|
|
||||||
await loadCronRuns(cronHost, cronHost.cronRunsJobId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ import {
|
|||||||
} from "./app-tool-stream.ts";
|
} from "./app-tool-stream.ts";
|
||||||
import type { AppViewState } from "./app-view-state.ts";
|
import type { AppViewState } from "./app-view-state.ts";
|
||||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||||
|
import { exportChatMarkdown } from "./chat/export.ts";
|
||||||
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts";
|
||||||
import type { CronFieldErrors } from "./controllers/cron.ts";
|
|
||||||
import type { DevicePairingList } from "./controllers/devices.ts";
|
import type { DevicePairingList } from "./controllers/devices.ts";
|
||||||
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
import type { ExecApprovalRequest } from "./controllers/exec-approval.ts";
|
||||||
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts";
|
||||||
@@ -62,7 +62,7 @@ import type { SkillMessage } from "./controllers/skills.ts";
|
|||||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
|
||||||
import type { Tab } from "./navigation.ts";
|
import type { Tab } from "./navigation.ts";
|
||||||
import { loadSettings, type UiSettings } from "./storage.ts";
|
import { loadSettings, type UiSettings } from "./storage.ts";
|
||||||
import type { ResolvedTheme, ThemeMode, ThemeName } from "./theme.ts";
|
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";
|
||||||
import type {
|
import type {
|
||||||
AgentsListResult,
|
AgentsListResult,
|
||||||
AgentsFilesListResult,
|
AgentsFilesListResult,
|
||||||
@@ -72,16 +72,17 @@ import type {
|
|||||||
CronJob,
|
CronJob,
|
||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
CronStatus,
|
CronStatus,
|
||||||
HealthSnapshot,
|
HealthSummary,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
ModelCatalogEntry,
|
||||||
PresenceEntry,
|
PresenceEntry,
|
||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
SkillStatusReport,
|
SkillStatusReport,
|
||||||
ToolsCatalogResult,
|
|
||||||
StatusSummary,
|
StatusSummary,
|
||||||
NostrProfile,
|
NostrProfile,
|
||||||
|
ToolsCatalogResult,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
|
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts";
|
||||||
import { generateUUID } from "./uuid.ts";
|
import { generateUUID } from "./uuid.ts";
|
||||||
@@ -121,12 +122,15 @@ export class OpenClawApp extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@state() password = "";
|
@state() password = "";
|
||||||
|
@state() loginShowGatewayToken = false;
|
||||||
|
@state() loginShowGatewayPassword = false;
|
||||||
@state() tab: Tab = "chat";
|
@state() tab: Tab = "chat";
|
||||||
@state() onboarding = resolveOnboardingMode();
|
@state() onboarding = resolveOnboardingMode();
|
||||||
@state() connected = false;
|
@state() connected = false;
|
||||||
@state() theme: ThemeName = this.settings.theme;
|
@state() theme: ThemeName = this.settings.theme ?? "claw";
|
||||||
@state() themeMode: ThemeMode = this.settings.themeMode;
|
@state() themeMode: ThemeMode = this.settings.themeMode ?? "system";
|
||||||
@state() themeResolved: ResolvedTheme = "dark";
|
@state() themeResolved: ResolvedTheme = "dark";
|
||||||
|
@state() themeOrder: ThemeName[] = this.buildThemeOrder(this.theme);
|
||||||
@state() hello: GatewayHelloOk | null = null;
|
@state() hello: GatewayHelloOk | null = null;
|
||||||
@state() lastError: string | null = null;
|
@state() lastError: string | null = null;
|
||||||
@state() lastErrorCode: string | null = null;
|
@state() lastErrorCode: string | null = null;
|
||||||
@@ -157,6 +161,9 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@state() chatAttachments: ChatAttachment[] = [];
|
@state() chatAttachments: ChatAttachment[] = [];
|
||||||
@state() chatManualRefreshInFlight = false;
|
@state() chatManualRefreshInFlight = false;
|
||||||
|
|
||||||
|
onSlashAction?: (action: string) => void;
|
||||||
|
|
||||||
// Sidebar state for tool output viewing
|
// Sidebar state for tool output viewing
|
||||||
@state() sidebarOpen = false;
|
@state() sidebarOpen = false;
|
||||||
@state() sidebarContent: string | null = null;
|
@state() sidebarContent: string | null = null;
|
||||||
@@ -203,6 +210,26 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() configSearchQuery = "";
|
@state() configSearchQuery = "";
|
||||||
@state() configActiveSection: string | null = null;
|
@state() configActiveSection: string | null = null;
|
||||||
@state() configActiveSubsection: string | null = null;
|
@state() configActiveSubsection: string | null = null;
|
||||||
|
@state() communicationsFormMode: "form" | "raw" = "form";
|
||||||
|
@state() communicationsSearchQuery = "";
|
||||||
|
@state() communicationsActiveSection: string | null = null;
|
||||||
|
@state() communicationsActiveSubsection: string | null = null;
|
||||||
|
@state() appearanceFormMode: "form" | "raw" = "form";
|
||||||
|
@state() appearanceSearchQuery = "";
|
||||||
|
@state() appearanceActiveSection: string | null = null;
|
||||||
|
@state() appearanceActiveSubsection: string | null = null;
|
||||||
|
@state() automationFormMode: "form" | "raw" = "form";
|
||||||
|
@state() automationSearchQuery = "";
|
||||||
|
@state() automationActiveSection: string | null = null;
|
||||||
|
@state() automationActiveSubsection: string | null = null;
|
||||||
|
@state() infrastructureFormMode: "form" | "raw" = "form";
|
||||||
|
@state() infrastructureSearchQuery = "";
|
||||||
|
@state() infrastructureActiveSection: string | null = null;
|
||||||
|
@state() infrastructureActiveSubsection: string | null = null;
|
||||||
|
@state() aiAgentsFormMode: "form" | "raw" = "form";
|
||||||
|
@state() aiAgentsSearchQuery = "";
|
||||||
|
@state() aiAgentsActiveSection: string | null = null;
|
||||||
|
@state() aiAgentsActiveSubsection: string | null = null;
|
||||||
|
|
||||||
@state() channelsLoading = false;
|
@state() channelsLoading = false;
|
||||||
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;
|
||||||
@@ -252,6 +279,12 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() sessionsIncludeGlobal = true;
|
@state() sessionsIncludeGlobal = true;
|
||||||
@state() sessionsIncludeUnknown = false;
|
@state() sessionsIncludeUnknown = false;
|
||||||
@state() sessionsHideCron = true;
|
@state() sessionsHideCron = true;
|
||||||
|
@state() sessionsSearchQuery = "";
|
||||||
|
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
|
||||||
|
@state() sessionsSortDir: "asc" | "desc" = "desc";
|
||||||
|
@state() sessionsPage = 0;
|
||||||
|
@state() sessionsPageSize = 10;
|
||||||
|
@state() sessionsActionsOpenKey: string | null = null;
|
||||||
|
|
||||||
@state() usageLoading = false;
|
@state() usageLoading = false;
|
||||||
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
@state() usageResult: import("./types.js").SessionsUsageResult | null = null;
|
||||||
@@ -326,7 +359,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() cronStatus: CronStatus | null = null;
|
@state() cronStatus: CronStatus | null = null;
|
||||||
@state() cronError: string | null = null;
|
@state() cronError: string | null = null;
|
||||||
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
|
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
|
||||||
@state() cronFieldErrors: CronFieldErrors = {};
|
@state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {};
|
||||||
@state() cronEditingJobId: string | null = null;
|
@state() cronEditingJobId: string | null = null;
|
||||||
@state() cronRunsJobId: string | null = null;
|
@state() cronRunsJobId: string | null = null;
|
||||||
@state() cronRunsLoadingMore = false;
|
@state() cronRunsLoadingMore = false;
|
||||||
@@ -346,6 +379,16 @@ export class OpenClawApp extends LitElement {
|
|||||||
|
|
||||||
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
|
@state() updateAvailable: import("./types.js").UpdateAvailable | null = null;
|
||||||
|
|
||||||
|
// Overview dashboard state
|
||||||
|
@state() attentionItems: import("./types.js").AttentionItem[] = [];
|
||||||
|
@state() paletteOpen = false;
|
||||||
|
@state() paletteQuery = "";
|
||||||
|
@state() paletteActiveIndex = 0;
|
||||||
|
@state() overviewShowGatewayToken = false;
|
||||||
|
@state() overviewShowGatewayPassword = false;
|
||||||
|
@state() overviewLogLines: string[] = [];
|
||||||
|
@state() overviewLogCursor = 0;
|
||||||
|
|
||||||
@state() skillsLoading = false;
|
@state() skillsLoading = false;
|
||||||
@state() skillsReport: SkillStatusReport | null = null;
|
@state() skillsReport: SkillStatusReport | null = null;
|
||||||
@state() skillsError: string | null = null;
|
@state() skillsError: string | null = null;
|
||||||
@@ -354,10 +397,14 @@ export class OpenClawApp extends LitElement {
|
|||||||
@state() skillsBusyKey: string | null = null;
|
@state() skillsBusyKey: string | null = null;
|
||||||
@state() skillMessages: Record<string, SkillMessage> = {};
|
@state() skillMessages: Record<string, SkillMessage> = {};
|
||||||
|
|
||||||
|
@state() healthLoading = false;
|
||||||
|
@state() healthResult: HealthSummary | null = null;
|
||||||
|
@state() healthError: string | null = null;
|
||||||
|
|
||||||
@state() debugLoading = false;
|
@state() debugLoading = false;
|
||||||
@state() debugStatus: StatusSummary | null = null;
|
@state() debugStatus: StatusSummary | null = null;
|
||||||
@state() debugHealth: HealthSnapshot | null = null;
|
@state() debugHealth: HealthSummary | null = null;
|
||||||
@state() debugModels: unknown[] = [];
|
@state() debugModels: ModelCatalogEntry[] = [];
|
||||||
@state() debugHeartbeat: unknown = null;
|
@state() debugHeartbeat: unknown = null;
|
||||||
@state() debugCallMethod = "";
|
@state() debugCallMethod = "";
|
||||||
@state() debugCallParams = "{}";
|
@state() debugCallParams = "{}";
|
||||||
@@ -396,9 +443,17 @@ export class OpenClawApp extends LitElement {
|
|||||||
basePath = "";
|
basePath = "";
|
||||||
private popStateHandler = () =>
|
private popStateHandler = () =>
|
||||||
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
|
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
|
||||||
private themeMedia: MediaQueryList | null = null;
|
|
||||||
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
|
|
||||||
private topbarObserver: ResizeObserver | null = null;
|
private topbarObserver: ResizeObserver | null = null;
|
||||||
|
private globalKeydownHandler = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "k") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.paletteOpen = !this.paletteOpen;
|
||||||
|
if (this.paletteOpen) {
|
||||||
|
this.paletteQuery = "";
|
||||||
|
this.paletteActiveIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
createRenderRoot() {
|
createRenderRoot() {
|
||||||
return this;
|
return this;
|
||||||
@@ -406,6 +461,20 @@ export class OpenClawApp extends LitElement {
|
|||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.onSlashAction = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case "toggle-focus":
|
||||||
|
this.applySettings({
|
||||||
|
...this.settings,
|
||||||
|
chatFocusMode: !this.settings.chatFocusMode,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "export":
|
||||||
|
exportChatMarkdown(this.chatMessages, this.assistantName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", this.globalKeydownHandler);
|
||||||
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
|
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +483,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
document.removeEventListener("keydown", this.globalKeydownHandler);
|
||||||
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
@@ -475,6 +545,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
|
|
||||||
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
|
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
|
||||||
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
|
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
|
||||||
|
this.themeOrder = this.buildThemeOrder(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
|
setThemeMode(next: ThemeMode, context?: Parameters<typeof setThemeModeInternal>[2]) {
|
||||||
@@ -485,6 +556,12 @@ export class OpenClawApp extends LitElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildThemeOrder(active: ThemeName): ThemeName[] {
|
||||||
|
const all = [...VALID_THEME_NAMES];
|
||||||
|
const rest = all.filter((id) => id !== active);
|
||||||
|
return [active, ...rest];
|
||||||
|
}
|
||||||
|
|
||||||
async loadOverview() {
|
async loadOverview() {
|
||||||
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
private save(): void {
|
||||||
|
try {
|
||||||
localStorage.setItem(this.key, JSON.stringify([...this._keys]));
|
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 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 {
|
export function exportChatMarkdown(messages: unknown[], assistantName: string): void {
|
||||||
const markdown = buildChatMarkdown(messages, assistantName);
|
const markdown = buildChatMarkdown(messages, assistantName);
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
@@ -62,7 +12,23 @@ export function exportChatMarkdown(messages: unknown[], assistantName: string):
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = buildChatExportFilename(assistantName);
|
link.download = `chat-${assistantName}-${Date.now()}.md`;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildChatMarkdown(messages: unknown[], assistantName: string): string | null {
|
||||||
|
const history = Array.isArray(messages) ? messages : [];
|
||||||
|
if (history.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lines: string[] = [`# Chat with ${assistantName}`, ""];
|
||||||
|
for (const msg of history) {
|
||||||
|
const m = msg as Record<string, unknown>;
|
||||||
|
const role = m.role === "user" ? "You" : m.role === "assistant" ? assistantName : "Tool";
|
||||||
|
const content = extractTextCached(msg) ?? "";
|
||||||
|
const ts = typeof m.timestamp === "number" ? new Date(m.timestamp).toISOString() : "";
|
||||||
|
lines.push(`## ${role}${ts ? ` (${ts})` : ""}`, "", content, "");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||||
|
import { icons } from "../icons.ts";
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||||
import { openExternalUrlSafe } from "../open-external-url.ts";
|
import { openExternalUrlSafe } from "../open-external-url.ts";
|
||||||
import { detectTextDirection } from "../text-direction.ts";
|
import { detectTextDirection } from "../text-direction.ts";
|
||||||
import type { MessageGroup } from "../types/chat-types.ts";
|
import type { MessageGroup, ToolCard } from "../types/chat-types.ts";
|
||||||
|
import { agentLogoUrl } from "../views/agents-utils.ts";
|
||||||
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
|
||||||
import {
|
import {
|
||||||
extractTextCached,
|
extractTextCached,
|
||||||
@@ -12,6 +14,7 @@ import {
|
|||||||
formatReasoningMarkdown,
|
formatReasoningMarkdown,
|
||||||
} from "./message-extract.ts";
|
} from "./message-extract.ts";
|
||||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
|
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer.ts";
|
||||||
|
import { isTtsSupported, speakText, stopTts, isTtsSpeaking } from "./speech.ts";
|
||||||
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
|
import { extractToolCards, renderToolCardSidebar } from "./tool-cards.ts";
|
||||||
|
|
||||||
type ImageBlock = {
|
type ImageBlock = {
|
||||||
@@ -56,10 +59,10 @@ function extractImages(message: unknown): ImageBlock[] {
|
|||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
|
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-group assistant">
|
<div class="chat-group assistant">
|
||||||
${renderAvatar("assistant", assistant)}
|
${renderAvatar("assistant", assistant, basePath)}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
|
||||||
<span class="chat-reading-indicator__dots">
|
<span class="chat-reading-indicator__dots">
|
||||||
@@ -76,6 +79,7 @@ export function renderStreamingGroup(
|
|||||||
startedAt: number,
|
startedAt: number,
|
||||||
onOpenSidebar?: (content: string) => void,
|
onOpenSidebar?: (content: string) => void,
|
||||||
assistant?: AssistantIdentity,
|
assistant?: AssistantIdentity,
|
||||||
|
basePath?: string,
|
||||||
) {
|
) {
|
||||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
@@ -85,7 +89,7 @@ export function renderStreamingGroup(
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-group assistant">
|
<div class="chat-group assistant">
|
||||||
${renderAvatar("assistant", assistant)}
|
${renderAvatar("assistant", assistant, basePath)}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
${renderGroupedMessage(
|
${renderGroupedMessage(
|
||||||
{
|
{
|
||||||
@@ -112,6 +116,9 @@ export function renderMessageGroup(
|
|||||||
showReasoning: boolean;
|
showReasoning: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
assistantAvatar?: string | null;
|
assistantAvatar?: string | null;
|
||||||
|
basePath?: string;
|
||||||
|
contextWindow?: number | null;
|
||||||
|
onDelete?: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const normalizedRole = normalizeRoleForGrouping(group.role);
|
const normalizedRole = normalizeRoleForGrouping(group.role);
|
||||||
@@ -122,20 +129,35 @@ export function renderMessageGroup(
|
|||||||
? (userLabel ?? "You")
|
? (userLabel ?? "You")
|
||||||
: normalizedRole === "assistant"
|
: normalizedRole === "assistant"
|
||||||
? assistantName
|
? assistantName
|
||||||
|
: normalizedRole === "tool"
|
||||||
|
? "Tool"
|
||||||
: normalizedRole;
|
: normalizedRole;
|
||||||
const roleClass =
|
const roleClass =
|
||||||
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other";
|
normalizedRole === "user"
|
||||||
|
? "user"
|
||||||
|
: normalizedRole === "assistant"
|
||||||
|
? "assistant"
|
||||||
|
: normalizedRole === "tool"
|
||||||
|
? "tool"
|
||||||
|
: "other";
|
||||||
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Aggregate usage/cost/model across all messages in the group
|
||||||
|
const meta = extractGroupMeta(group, opts.contextWindow ?? null);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-group ${roleClass}">
|
<div class="chat-group ${roleClass}">
|
||||||
${renderAvatar(group.role, {
|
${renderAvatar(
|
||||||
|
group.role,
|
||||||
|
{
|
||||||
name: assistantName,
|
name: assistantName,
|
||||||
avatar: opts.assistantAvatar ?? null,
|
avatar: opts.assistantAvatar ?? null,
|
||||||
})}
|
},
|
||||||
|
opts.basePath,
|
||||||
|
)}
|
||||||
<div class="chat-group-messages">
|
<div class="chat-group-messages">
|
||||||
${group.messages.map((item, index) =>
|
${group.messages.map((item, index) =>
|
||||||
renderGroupedMessage(
|
renderGroupedMessage(
|
||||||
@@ -150,24 +172,304 @@ export function renderMessageGroup(
|
|||||||
<div class="chat-group-footer">
|
<div class="chat-group-footer">
|
||||||
<span class="chat-sender-name">${who}</span>
|
<span class="chat-sender-name">${who}</span>
|
||||||
<span class="chat-group-timestamp">${timestamp}</span>
|
<span class="chat-group-timestamp">${timestamp}</span>
|
||||||
|
${renderMessageMeta(meta)}
|
||||||
|
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
|
||||||
|
${opts.onDelete ? renderDeleteButton(opts.onDelete) : nothing}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
|
// ── Per-message metadata (tokens, cost, model, context %) ──
|
||||||
|
|
||||||
|
type GroupMeta = {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
cost: number;
|
||||||
|
model: string | null;
|
||||||
|
contextPercent: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractGroupMeta(group: MessageGroup, contextWindow: number | null): GroupMeta | null {
|
||||||
|
let input = 0;
|
||||||
|
let output = 0;
|
||||||
|
let cacheRead = 0;
|
||||||
|
let cacheWrite = 0;
|
||||||
|
let cost = 0;
|
||||||
|
let model: string | null = null;
|
||||||
|
let hasUsage = false;
|
||||||
|
|
||||||
|
for (const { message } of group.messages) {
|
||||||
|
const m = message as Record<string, unknown>;
|
||||||
|
if (m.role !== "assistant") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const usage = m.usage as Record<string, number> | undefined;
|
||||||
|
if (usage) {
|
||||||
|
hasUsage = true;
|
||||||
|
input += usage.input ?? usage.inputTokens ?? 0;
|
||||||
|
output += usage.output ?? usage.outputTokens ?? 0;
|
||||||
|
cacheRead += usage.cacheRead ?? usage.cache_read_input_tokens ?? 0;
|
||||||
|
cacheWrite += usage.cacheWrite ?? usage.cache_creation_input_tokens ?? 0;
|
||||||
|
}
|
||||||
|
const c = m.cost as Record<string, number> | undefined;
|
||||||
|
if (c?.total) {
|
||||||
|
cost += c.total;
|
||||||
|
}
|
||||||
|
if (typeof m.model === "string" && m.model !== "gateway-injected") {
|
||||||
|
model = m.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasUsage && !model) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextPercent =
|
||||||
|
contextWindow && input > 0 ? Math.min(Math.round((input / contextWindow) * 100), 100) : null;
|
||||||
|
|
||||||
|
return { input, output, cacheRead, cacheWrite, cost, model, contextPercent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact token count formatter (e.g. 128000 → "128k"). */
|
||||||
|
function fmtTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) {
|
||||||
|
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||||
|
}
|
||||||
|
if (n >= 1_000) {
|
||||||
|
return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||||
|
}
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageMeta(meta: GroupMeta | null) {
|
||||||
|
if (!meta) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: Array<ReturnType<typeof html>> = [];
|
||||||
|
|
||||||
|
// Token counts: ↑input ↓output
|
||||||
|
if (meta.input) {
|
||||||
|
parts.push(html`<span class="msg-meta__tokens">↑${fmtTokens(meta.input)}</span>`);
|
||||||
|
}
|
||||||
|
if (meta.output) {
|
||||||
|
parts.push(html`<span class="msg-meta__tokens">↓${fmtTokens(meta.output)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache: R/W
|
||||||
|
if (meta.cacheRead) {
|
||||||
|
parts.push(html`<span class="msg-meta__cache">R${fmtTokens(meta.cacheRead)}</span>`);
|
||||||
|
}
|
||||||
|
if (meta.cacheWrite) {
|
||||||
|
parts.push(html`<span class="msg-meta__cache">W${fmtTokens(meta.cacheWrite)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost
|
||||||
|
if (meta.cost > 0) {
|
||||||
|
parts.push(html`<span class="msg-meta__cost">$${meta.cost.toFixed(4)}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context %
|
||||||
|
if (meta.contextPercent !== null) {
|
||||||
|
const pct = meta.contextPercent;
|
||||||
|
const cls =
|
||||||
|
pct >= 90
|
||||||
|
? "msg-meta__ctx msg-meta__ctx--danger"
|
||||||
|
: pct >= 75
|
||||||
|
? "msg-meta__ctx msg-meta__ctx--warn"
|
||||||
|
: "msg-meta__ctx";
|
||||||
|
parts.push(html`<span class="${cls}">${pct}% ctx</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model
|
||||||
|
if (meta.model) {
|
||||||
|
// Shorten model name: strip provider prefix if present (e.g. "anthropic/claude-3.5-sonnet" → "claude-3.5-sonnet")
|
||||||
|
const shortModel = meta.model.includes("/") ? meta.model.split("/").pop()! : meta.model;
|
||||||
|
parts.push(html`<span class="msg-meta__model">${shortModel}</span>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<span class="msg-meta">${parts}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGroupText(group: MessageGroup): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const { message } of group.messages) {
|
||||||
|
const text = extractTextCached(message);
|
||||||
|
if (text?.trim()) {
|
||||||
|
parts.push(text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKIP_DELETE_CONFIRM_KEY = "openclaw:skipDeleteConfirm";
|
||||||
|
|
||||||
|
function shouldSkipDeleteConfirm(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SKIP_DELETE_CONFIRM_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeleteButton(onDelete: () => void) {
|
||||||
|
return html`
|
||||||
|
<span class="chat-delete-wrap">
|
||||||
|
<button
|
||||||
|
class="chat-group-delete"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete message"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
if (shouldSkipDeleteConfirm()) {
|
||||||
|
onDelete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const wrap = btn.closest(".chat-delete-wrap") as HTMLElement;
|
||||||
|
const existing = wrap?.querySelector(".chat-delete-confirm");
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popover = document.createElement("div");
|
||||||
|
popover.className = "chat-delete-confirm";
|
||||||
|
popover.innerHTML = `
|
||||||
|
<p class="chat-delete-confirm__text">Delete this message?</p>
|
||||||
|
<label class="chat-delete-confirm__remember">
|
||||||
|
<input type="checkbox" class="chat-delete-confirm__check" />
|
||||||
|
<span>Don't ask again</span>
|
||||||
|
</label>
|
||||||
|
<div class="chat-delete-confirm__actions">
|
||||||
|
<button class="chat-delete-confirm__cancel" type="button">Cancel</button>
|
||||||
|
<button class="chat-delete-confirm__yes" type="button">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(popover);
|
||||||
|
|
||||||
|
const cancel = popover.querySelector(".chat-delete-confirm__cancel")!;
|
||||||
|
const yes = popover.querySelector(".chat-delete-confirm__yes")!;
|
||||||
|
const check = popover.querySelector(".chat-delete-confirm__check") as HTMLInputElement;
|
||||||
|
|
||||||
|
cancel.addEventListener("click", () => popover.remove());
|
||||||
|
yes.addEventListener("click", () => {
|
||||||
|
if (check.checked) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SKIP_DELETE_CONFIRM_KEY, "1");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
popover.remove();
|
||||||
|
onDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
const closeOnOutside = (evt: MouseEvent) => {
|
||||||
|
if (!popover.contains(evt.target as Node) && evt.target !== btn) {
|
||||||
|
popover.remove();
|
||||||
|
document.removeEventListener("click", closeOnOutside, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => document.addEventListener("click", closeOnOutside, true));
|
||||||
|
}}
|
||||||
|
>${icons.trash ?? icons.x}</button>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTtsButton(group: MessageGroup) {
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="chat-tts-btn"
|
||||||
|
type="button"
|
||||||
|
title=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
|
||||||
|
aria-label=${isTtsSpeaking() ? "Stop speaking" : "Read aloud"}
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
const btn = e.currentTarget as HTMLButtonElement;
|
||||||
|
if (isTtsSpeaking()) {
|
||||||
|
stopTts();
|
||||||
|
btn.classList.remove("chat-tts-btn--active");
|
||||||
|
btn.title = "Read aloud";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = extractGroupText(group);
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.classList.add("chat-tts-btn--active");
|
||||||
|
btn.title = "Stop speaking";
|
||||||
|
speakText(text, {
|
||||||
|
onEnd: () => {
|
||||||
|
if (btn.isConnected) {
|
||||||
|
btn.classList.remove("chat-tts-btn--active");
|
||||||
|
btn.title = "Read aloud";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
if (btn.isConnected) {
|
||||||
|
btn.classList.remove("chat-tts-btn--active");
|
||||||
|
btn.title = "Read aloud";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${icons.volume2}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvatar(
|
||||||
|
role: string,
|
||||||
|
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
|
||||||
|
basePath?: string,
|
||||||
|
) {
|
||||||
const normalized = normalizeRoleForGrouping(role);
|
const normalized = normalizeRoleForGrouping(role);
|
||||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||||
const initial =
|
const initial =
|
||||||
normalized === "user"
|
normalized === "user"
|
||||||
? "U"
|
? html`
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M20 21a8 8 0 1 0-16 0" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
: normalized === "assistant"
|
: normalized === "assistant"
|
||||||
? assistantName.charAt(0).toUpperCase() || "A"
|
? html`
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||||
|
<path d="M12 2l2.4 7.2H22l-6 4.8 2.4 7.2L12 16l-6.4 5.2L8 14 2 9.2h7.6z" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
: normalized === "tool"
|
: normalized === "tool"
|
||||||
? "⚙"
|
? html`
|
||||||
: "?";
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||||
|
<path
|
||||||
|
d="M12 15.5A3.5 3.5 0 0 1 8.5 12 3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5 3.5 3.5 0 0 1-3.5 3.5m7.43-2.53a7.76 7.76 0 0 0 .07-1 7.76 7.76 0 0 0-.07-.97l2.11-1.63a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.15 7.15 0 0 0-1.69-.98l-.38-2.65A.49.49 0 0 0 14 2h-4a.49.49 0 0 0-.49.42l-.38 2.65a7.15 7.15 0 0 0-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.49.49 0 0 0 .12.64L4.57 11a7.9 7.9 0 0 0 0 1.94l-2.11 1.69a.49.49 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.72 1.69.98l.38 2.65c.05.24.26.42.49.42h4c.23 0 .44-.18.49-.42l.38-2.65a7.15 7.15 0 0 0 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.49.49 0 0 0-.12-.64z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="16.5"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="14"
|
||||||
|
font-weight="600"
|
||||||
|
fill="var(--bg, #fff)"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
const className =
|
const className =
|
||||||
normalized === "user"
|
normalized === "user"
|
||||||
? "user"
|
? "user"
|
||||||
@@ -185,7 +487,21 @@ function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" |
|
|||||||
alt="${assistantName}"
|
alt="${assistantName}"
|
||||||
/>`;
|
/>`;
|
||||||
}
|
}
|
||||||
return html`<div class="chat-avatar ${className}">${assistantAvatar}</div>`;
|
return html`<img
|
||||||
|
class="chat-avatar ${className} chat-avatar--logo"
|
||||||
|
src="${agentLogoUrl(basePath ?? "")}"
|
||||||
|
alt="${assistantName}"
|
||||||
|
/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Assistant with no custom avatar: use logo when basePath available */
|
||||||
|
if (normalized === "assistant" && basePath) {
|
||||||
|
const logoUrl = agentLogoUrl(basePath);
|
||||||
|
return html`<img
|
||||||
|
class="chat-avatar ${className} chat-avatar--logo"
|
||||||
|
src="${logoUrl}"
|
||||||
|
alt="${assistantName}"
|
||||||
|
/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||||
@@ -222,6 +538,79 @@ function renderMessageImages(images: ImageBlock[]) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render tool cards inside a collapsed `<details>` element. */
|
||||||
|
function renderCollapsedToolCards(
|
||||||
|
toolCards: ToolCard[],
|
||||||
|
onOpenSidebar?: (content: string) => void,
|
||||||
|
) {
|
||||||
|
const calls = toolCards.filter((c) => c.kind === "call");
|
||||||
|
const results = toolCards.filter((c) => c.kind === "result");
|
||||||
|
const totalTools = Math.max(calls.length, results.length) || toolCards.length;
|
||||||
|
const toolNames = [...new Set(toolCards.map((c) => c.name))];
|
||||||
|
const summaryLabel =
|
||||||
|
toolNames.length <= 3
|
||||||
|
? toolNames.join(", ")
|
||||||
|
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<details class="chat-tools-collapse">
|
||||||
|
<summary class="chat-tools-summary">
|
||||||
|
<span class="chat-tools-summary__icon">${icons.zap}</span>
|
||||||
|
<span class="chat-tools-summary__count">${totalTools} tool${totalTools === 1 ? "" : "s"}</span>
|
||||||
|
<span class="chat-tools-summary__names">${summaryLabel}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="chat-tools-collapse__body">
|
||||||
|
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max characters for auto-detecting and pretty-printing JSON.
|
||||||
|
* Prevents DoS from large JSON payloads in assistant/tool messages.
|
||||||
|
*/
|
||||||
|
const MAX_JSON_AUTOPARSE_CHARS = 20_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether a trimmed string is a JSON object or array.
|
||||||
|
* Must start with `{`/`[` and end with `}`/`]` and parse successfully.
|
||||||
|
* Size-capped to prevent render-loop DoS from large JSON messages.
|
||||||
|
*/
|
||||||
|
function detectJson(text: string): { parsed: unknown; pretty: string } | null {
|
||||||
|
const t = text.trim();
|
||||||
|
|
||||||
|
// Enforce size cap to prevent UI freeze from multi-MB JSON payloads
|
||||||
|
if (t.length > MAX_JSON_AUTOPARSE_CHARS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(t);
|
||||||
|
return { parsed, pretty: JSON.stringify(parsed, null, 2) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a short summary label for collapsed JSON (type + key count or array length). */
|
||||||
|
function jsonSummaryLabel(parsed: unknown): string {
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return `Array (${parsed.length} item${parsed.length === 1 ? "" : "s"})`;
|
||||||
|
}
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
const keys = Object.keys(parsed as Record<string, unknown>);
|
||||||
|
if (keys.length <= 4) {
|
||||||
|
return `{ ${keys.join(", ")} }`;
|
||||||
|
}
|
||||||
|
return `Object (${keys.length} keys)`;
|
||||||
|
}
|
||||||
|
return "JSON";
|
||||||
|
}
|
||||||
|
|
||||||
function renderGroupedMessage(
|
function renderGroupedMessage(
|
||||||
message: unknown,
|
message: unknown,
|
||||||
opts: { isStreaming: boolean; showReasoning: boolean },
|
opts: { isStreaming: boolean; showReasoning: boolean },
|
||||||
@@ -229,6 +618,7 @@ function renderGroupedMessage(
|
|||||||
) {
|
) {
|
||||||
const m = message as Record<string, unknown>;
|
const m = message as Record<string, unknown>;
|
||||||
const role = typeof m.role === "string" ? m.role : "unknown";
|
const role = typeof m.role === "string" ? m.role : "unknown";
|
||||||
|
const normalizedRole = normalizeRoleForGrouping(role);
|
||||||
const isToolResult =
|
const isToolResult =
|
||||||
isToolResultMessage(message) ||
|
isToolResultMessage(message) ||
|
||||||
role.toLowerCase() === "toolresult" ||
|
role.toLowerCase() === "toolresult" ||
|
||||||
@@ -249,26 +639,49 @@ function renderGroupedMessage(
|
|||||||
const markdown = markdownBase;
|
const markdown = markdownBase;
|
||||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||||
|
|
||||||
const bubbleClasses = [
|
// Detect pure-JSON messages and render as collapsible block
|
||||||
"chat-bubble",
|
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
|
||||||
canCopyMarkdown ? "has-copy" : "",
|
|
||||||
opts.isStreaming ? "streaming" : "",
|
const bubbleClasses = ["chat-bubble", opts.isStreaming ? "streaming" : "", "fade-in"]
|
||||||
"fade-in",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
if (!markdown && hasToolCards && isToolResult) {
|
if (!markdown && hasToolCards && isToolResult) {
|
||||||
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
|
return renderCollapsedToolCards(toolCards, onOpenSidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!markdown && !hasToolCards && !hasImages) {
|
if (!markdown && !hasToolCards && !hasImages) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isToolMessage = normalizedRole === "tool" || isToolResult;
|
||||||
|
const toolNames = [...new Set(toolCards.map((c) => c.name))];
|
||||||
|
const toolSummaryLabel =
|
||||||
|
toolNames.length <= 3
|
||||||
|
? toolNames.join(", ")
|
||||||
|
: `${toolNames.slice(0, 2).join(", ")} +${toolNames.length - 2} more`;
|
||||||
|
const toolPreview =
|
||||||
|
markdown && !toolSummaryLabel ? markdown.trim().replace(/\s+/g, " ").slice(0, 120) : "";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="${bubbleClasses}">
|
<div class="${bubbleClasses}">
|
||||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
${canCopyMarkdown ? html`<div class="chat-bubble-actions">${renderCopyAsMarkdownButton(markdown!)}</div>` : nothing}
|
||||||
|
${
|
||||||
|
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)}
|
${renderMessageImages(images)}
|
||||||
${
|
${
|
||||||
reasoningMarkdown
|
reasoningMarkdown
|
||||||
@@ -278,11 +691,47 @@ function renderGroupedMessage(
|
|||||||
: nothing
|
: 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>`
|
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
: nothing
|
: 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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export class PinnedMessages {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private save(): void {
|
private save(): void {
|
||||||
|
try {
|
||||||
localStorage.setItem(this.key, JSON.stringify([...this._indices]));
|
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 type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js";
|
||||||
import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js";
|
|
||||||
import {
|
import {
|
||||||
formatThinkingLevels,
|
formatThinkingLevels,
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
normalizeVerboseLevel,
|
||||||
|
resolveThinkingDefaultForModel,
|
||||||
} from "../../../../src/auto-reply/thinking.js";
|
} from "../../../../src/auto-reply/thinking.js";
|
||||||
import type { HealthSummary } from "../../../../src/commands/health.js";
|
import type { HealthSummary } from "../../../../src/commands/health.js";
|
||||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENT_ID,
|
DEFAULT_AGENT_ID,
|
||||||
DEFAULT_MAIN_KEY,
|
DEFAULT_MAIN_KEY,
|
||||||
@@ -176,6 +175,7 @@ async function executeThink(
|
|||||||
args: string,
|
args: string,
|
||||||
): Promise<SlashCommandResult> {
|
): Promise<SlashCommandResult> {
|
||||||
const rawLevel = args.trim();
|
const rawLevel = args.trim();
|
||||||
|
|
||||||
if (!rawLevel) {
|
if (!rawLevel) {
|
||||||
try {
|
try {
|
||||||
const { session, models } = await loadThinkingCommandState(client, sessionKey);
|
const { session, models } = await loadThinkingCommandState(client, sessionKey);
|
||||||
@@ -219,6 +219,7 @@ async function executeVerbose(
|
|||||||
args: string,
|
args: string,
|
||||||
): Promise<SlashCommandResult> {
|
): Promise<SlashCommandResult> {
|
||||||
const rawLevel = args.trim();
|
const rawLevel = args.trim();
|
||||||
|
|
||||||
if (!rawLevel) {
|
if (!rawLevel) {
|
||||||
try {
|
try {
|
||||||
const session = await loadCurrentSession(client, sessionKey);
|
const session = await loadCurrentSession(client, sessionKey);
|
||||||
@@ -526,8 +527,7 @@ function resolveCurrentThinkingLevel(
|
|||||||
if (!session?.modelProvider || !session.model) {
|
if (!session?.modelProvider || !session.model) {
|
||||||
return "off";
|
return "off";
|
||||||
}
|
}
|
||||||
return resolveThinkingDefault({
|
return resolveThinkingDefaultForModel({
|
||||||
cfg: {} as OpenClawConfig,
|
|
||||||
provider: session.modelProvider,
|
provider: session.modelProvider,
|
||||||
model: session.model,
|
model: session.model,
|
||||||
catalog: models,
|
catalog: models,
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|||||||
{
|
{
|
||||||
name: "new",
|
name: "new",
|
||||||
description: "Start a new session",
|
description: "Start a new session",
|
||||||
icon: "circle",
|
icon: "plus",
|
||||||
category: "session",
|
category: "session",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "reset",
|
name: "reset",
|
||||||
description: "Reset current session",
|
description: "Reset current session",
|
||||||
icon: "loader",
|
icon: "refresh",
|
||||||
category: "session",
|
category: "session",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
@@ -42,21 +42,21 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|||||||
{
|
{
|
||||||
name: "stop",
|
name: "stop",
|
||||||
description: "Stop current run",
|
description: "Stop current run",
|
||||||
icon: "x",
|
icon: "stop",
|
||||||
category: "session",
|
category: "session",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "clear",
|
name: "clear",
|
||||||
description: "Clear chat history",
|
description: "Clear chat history",
|
||||||
icon: "x",
|
icon: "trash",
|
||||||
category: "session",
|
category: "session",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "focus",
|
name: "focus",
|
||||||
description: "Toggle focus mode",
|
description: "Toggle focus mode",
|
||||||
icon: "search",
|
icon: "eye",
|
||||||
category: "session",
|
category: "session",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
@@ -77,13 +77,13 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|||||||
icon: "brain",
|
icon: "brain",
|
||||||
category: "model",
|
category: "model",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"],
|
argOptions: ["off", "low", "medium", "high"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "verbose",
|
name: "verbose",
|
||||||
description: "Toggle verbose mode",
|
description: "Toggle verbose mode",
|
||||||
args: "<on|off|full>",
|
args: "<on|off|full>",
|
||||||
icon: "fileCode",
|
icon: "terminal",
|
||||||
category: "model",
|
category: "model",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
argOptions: ["on", "off", "full"],
|
argOptions: ["on", "off", "full"],
|
||||||
@@ -107,7 +107,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|||||||
{
|
{
|
||||||
name: "export",
|
name: "export",
|
||||||
description: "Export session to Markdown",
|
description: "Export session to Markdown",
|
||||||
icon: "arrowDown",
|
icon: "download",
|
||||||
category: "tools",
|
category: "tools",
|
||||||
executeLocal: true,
|
executeLocal: true,
|
||||||
},
|
},
|
||||||
@@ -146,7 +146,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [
|
|||||||
name: "steer",
|
name: "steer",
|
||||||
description: "Steer a sub-agent",
|
description: "Steer a sub-agent",
|
||||||
args: "<id> <msg>",
|
args: "<id> <msg>",
|
||||||
icon: "zap",
|
icon: "send",
|
||||||
category: "agents",
|
category: "agents",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AgentsState = {
|
|||||||
agentsList: AgentsListResult | null;
|
agentsList: AgentsListResult | null;
|
||||||
agentsSelectedId: string | null;
|
agentsSelectedId: string | null;
|
||||||
toolsCatalogLoading: boolean;
|
toolsCatalogLoading: boolean;
|
||||||
|
toolsCatalogLoadingAgentId?: string | null;
|
||||||
toolsCatalogError: string | null;
|
toolsCatalogError: string | null;
|
||||||
toolsCatalogResult: ToolsCatalogResult | null;
|
toolsCatalogResult: ToolsCatalogResult | null;
|
||||||
};
|
};
|
||||||
@@ -43,29 +44,46 @@ export async function loadAgents(state: AgentsState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadToolsCatalog(state: AgentsState, agentId?: string | null) {
|
export async function loadToolsCatalog(state: AgentsState, agentId: string) {
|
||||||
if (!state.client || !state.connected) {
|
const resolvedAgentId = agentId.trim();
|
||||||
|
if (!state.client || !state.connected || !resolvedAgentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.toolsCatalogLoading) {
|
if (state.toolsCatalogLoading && state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.toolsCatalogLoading = true;
|
state.toolsCatalogLoading = true;
|
||||||
|
state.toolsCatalogLoadingAgentId = resolvedAgentId;
|
||||||
state.toolsCatalogError = null;
|
state.toolsCatalogError = null;
|
||||||
|
state.toolsCatalogResult = null;
|
||||||
try {
|
try {
|
||||||
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
|
const res = await state.client.request<ToolsCatalogResult>("tools.catalog", {
|
||||||
agentId: agentId ?? state.agentsSelectedId ?? undefined,
|
agentId: resolvedAgentId,
|
||||||
includePlugins: true,
|
includePlugins: true,
|
||||||
});
|
});
|
||||||
if (res) {
|
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||||
state.toolsCatalogResult = res;
|
return;
|
||||||
}
|
}
|
||||||
|
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.toolsCatalogResult = res;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (state.toolsCatalogLoadingAgentId !== resolvedAgentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.agentsSelectedId && state.agentsSelectedId !== resolvedAgentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.toolsCatalogResult = null;
|
||||||
state.toolsCatalogError = String(err);
|
state.toolsCatalogError = String(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (state.toolsCatalogLoadingAgentId === resolvedAgentId) {
|
||||||
|
state.toolsCatalogLoadingAgentId = null;
|
||||||
state.toolsCatalogLoading = false;
|
state.toolsCatalogLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAgentsConfig(state: AgentsConfigSaveState) {
|
export async function saveAgentsConfig(state: AgentsConfigSaveState) {
|
||||||
const selectedBefore = state.agentsSelectedId;
|
const selectedBefore = state.agentsSelectedId;
|
||||||
|
|||||||
@@ -184,9 +184,17 @@ export async function runUpdate(state: ConfigState) {
|
|||||||
state.updateRunning = true;
|
state.updateRunning = true;
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
try {
|
try {
|
||||||
await state.client.request("update.run", {
|
const res = await state.client.request<{
|
||||||
|
ok?: boolean;
|
||||||
|
result?: { status?: string; reason?: string };
|
||||||
|
}>("update.run", {
|
||||||
sessionKey: state.applySessionKey,
|
sessionKey: state.applySessionKey,
|
||||||
});
|
});
|
||||||
|
if (res && res.ok === false) {
|
||||||
|
const status = res.result?.status ?? "error";
|
||||||
|
const reason = res.result?.reason ?? "Update failed.";
|
||||||
|
state.lastError = `Update ${status}: ${reason}`;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.lastError = String(err);
|
state.lastError = String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -255,3 +263,21 @@ export function ensureAgentConfigEntry(state: ConfigState, agentId: string): num
|
|||||||
updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId);
|
updateConfigFormValue(state, ["agents", "list", nextIndex, "id"], normalizedAgentId);
|
||||||
return nextIndex;
|
return nextIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openConfigFile(state: ConfigState): Promise<void> {
|
||||||
|
if (!state.client || !state.connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await state.client.request("config.openFile", {});
|
||||||
|
} catch {
|
||||||
|
const path = state.configSnapshot?.path;
|
||||||
|
if (path) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(path);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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",
|
"b",
|
||||||
"blockquote",
|
"blockquote",
|
||||||
"br",
|
"br",
|
||||||
|
"button",
|
||||||
"code",
|
"code",
|
||||||
"del",
|
"del",
|
||||||
|
"details",
|
||||||
|
"div",
|
||||||
"em",
|
"em",
|
||||||
"h1",
|
"h1",
|
||||||
"h2",
|
"h2",
|
||||||
@@ -20,7 +23,9 @@ const allowedTags = [
|
|||||||
"ol",
|
"ol",
|
||||||
"p",
|
"p",
|
||||||
"pre",
|
"pre",
|
||||||
|
"span",
|
||||||
"strong",
|
"strong",
|
||||||
|
"summary",
|
||||||
"table",
|
"table",
|
||||||
"tbody",
|
"tbody",
|
||||||
"td",
|
"td",
|
||||||
@@ -31,7 +36,19 @@ const allowedTags = [
|
|||||||
"img",
|
"img",
|
||||||
];
|
];
|
||||||
|
|
||||||
const allowedAttrs = ["class", "href", "rel", "target", "title", "start", "src", "alt"];
|
const allowedAttrs = [
|
||||||
|
"class",
|
||||||
|
"href",
|
||||||
|
"rel",
|
||||||
|
"target",
|
||||||
|
"title",
|
||||||
|
"start",
|
||||||
|
"src",
|
||||||
|
"alt",
|
||||||
|
"data-code",
|
||||||
|
"type",
|
||||||
|
"aria-label",
|
||||||
|
];
|
||||||
const sanitizeOptions = {
|
const sanitizeOptions = {
|
||||||
ALLOWED_TAGS: allowedTags,
|
ALLOWED_TAGS: allowedTags,
|
||||||
ALLOWED_ATTR: allowedAttrs,
|
ALLOWED_ATTR: allowedAttrs,
|
||||||
@@ -45,6 +62,7 @@ const MARKDOWN_CACHE_LIMIT = 200;
|
|||||||
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
||||||
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
|
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
|
||||||
const markdownCache = new Map<string, string>();
|
const markdownCache = new Map<string, string>();
|
||||||
|
const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur";
|
||||||
|
|
||||||
function getCachedMarkdown(key: string): string | null {
|
function getCachedMarkdown(key: string): string | null {
|
||||||
const cached = markdownCache.get(key);
|
const cached = markdownCache.get(key);
|
||||||
@@ -83,6 +101,9 @@ function installHooks() {
|
|||||||
}
|
}
|
||||||
node.setAttribute("rel", "noreferrer noopener");
|
node.setAttribute("rel", "noreferrer noopener");
|
||||||
node.setAttribute("target", "_blank");
|
node.setAttribute("target", "_blank");
|
||||||
|
if (href.toLowerCase().includes("tail")) {
|
||||||
|
node.classList.add(TAIL_LINK_BLUR_CLASS);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +173,43 @@ function normalizeMarkdownImageLabel(text?: string | null): string {
|
|||||||
return trimmed ? trimmed : "image";
|
return trimmed ? trimmed : "image";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
htmlEscapeRenderer.code = ({
|
||||||
|
text,
|
||||||
|
lang,
|
||||||
|
escaped,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
lang?: string;
|
||||||
|
escaped?: boolean;
|
||||||
|
}) => {
|
||||||
|
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
|
||||||
|
const safeText = escaped ? text : escapeHtml(text);
|
||||||
|
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
|
||||||
|
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
|
||||||
|
const attrSafe = text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.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 {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ export const TAB_GROUPS = [
|
|||||||
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
|
{ label: "agent", tabs: ["agents", "skills", "nodes"] },
|
||||||
{
|
{
|
||||||
label: "settings",
|
label: "settings",
|
||||||
tabs: ["config", "debug", "logs"],
|
tabs: [
|
||||||
|
"config",
|
||||||
|
"communications",
|
||||||
|
"appearance",
|
||||||
|
"automation",
|
||||||
|
"infrastructure",
|
||||||
|
"aiAgents",
|
||||||
|
"debug",
|
||||||
|
"logs",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -55,19 +64,7 @@ const TAB_PATHS: Record<Tab, string> = {
|
|||||||
logs: "/logs",
|
logs: "/logs",
|
||||||
};
|
};
|
||||||
|
|
||||||
const HIDDEN_SETTINGS_TABS = new Set<Tab>([
|
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
|
||||||
"communications",
|
|
||||||
"appearance",
|
|
||||||
"automation",
|
|
||||||
"infrastructure",
|
|
||||||
"aiAgents",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const PATH_TO_TAB = new Map(
|
|
||||||
Object.entries(TAB_PATHS)
|
|
||||||
.filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab))
|
|
||||||
.map(([tab, path]) => [path, tab as Tab]),
|
|
||||||
);
|
|
||||||
|
|
||||||
export function normalizeBasePath(basePath: string): string {
|
export function normalizeBasePath(basePath: string): string {
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
|
|||||||
@@ -19,11 +19,40 @@ export type UiSettings = {
|
|||||||
chatShowThinking: boolean;
|
chatShowThinking: boolean;
|
||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
||||||
navCollapsed: boolean; // Collapsible sidebar state
|
navCollapsed: boolean; // Collapsible sidebar state
|
||||||
navWidth: number; // Sidebar width when expanded (200–400px)
|
navWidth: number; // Sidebar width when expanded (240–400px)
|
||||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||||
locale?: string;
|
locale?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isViteDevPage(): boolean {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(document.querySelector('script[src*="/@vite/client"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHostWithPort(hostname: string, port: string): string {
|
||||||
|
const normalizedHost = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||||
|
return `${normalizedHost}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveDefaultGatewayUrl(): { pageUrl: string; effectiveUrl: string } {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
const configured =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
|
||||||
|
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
|
||||||
|
const basePath = configured
|
||||||
|
? normalizeBasePath(configured)
|
||||||
|
: inferBasePathFromPathname(location.pathname);
|
||||||
|
const pageUrl = `${proto}://${location.host}${basePath}`;
|
||||||
|
if (!isViteDevPage()) {
|
||||||
|
return { pageUrl, effectiveUrl: pageUrl };
|
||||||
|
}
|
||||||
|
const effectiveUrl = `${proto}://${formatHostWithPort(location.hostname, "18789")}`;
|
||||||
|
return { pageUrl, effectiveUrl };
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionStorage(): Storage | null {
|
function getSessionStorage(): Storage | null {
|
||||||
if (typeof window !== "undefined" && window.sessionStorage) {
|
if (typeof window !== "undefined" && window.sessionStorage) {
|
||||||
return window.sessionStorage;
|
return window.sessionStorage;
|
||||||
@@ -91,17 +120,7 @@ function persistSessionToken(gatewayUrl: string, token: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function loadSettings(): UiSettings {
|
export function loadSettings(): UiSettings {
|
||||||
const defaultUrl = (() => {
|
const { pageUrl: pageDerivedUrl, effectiveUrl: defaultUrl } = deriveDefaultGatewayUrl();
|
||||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
||||||
const configured =
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.__OPENCLAW_CONTROL_UI_BASE_PATH__ === "string" &&
|
|
||||||
window.__OPENCLAW_CONTROL_UI_BASE_PATH__.trim();
|
|
||||||
const basePath = configured
|
|
||||||
? normalizeBasePath(configured)
|
|
||||||
: inferBasePathFromPathname(location.pathname);
|
|
||||||
return `${proto}://${location.host}${basePath}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const defaults: UiSettings = {
|
const defaults: UiSettings = {
|
||||||
gatewayUrl: defaultUrl,
|
gatewayUrl: defaultUrl,
|
||||||
@@ -124,21 +143,19 @@ export function loadSettings(): UiSettings {
|
|||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
||||||
|
const parsedGatewayUrl =
|
||||||
|
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||||
|
? parsed.gatewayUrl.trim()
|
||||||
|
: defaults.gatewayUrl;
|
||||||
|
const gatewayUrl = parsedGatewayUrl === pageDerivedUrl ? defaultUrl : parsedGatewayUrl;
|
||||||
const { theme, mode } = parseThemeSelection(
|
const { theme, mode } = parseThemeSelection(
|
||||||
(parsed as { theme?: unknown }).theme,
|
(parsed as { theme?: unknown }).theme,
|
||||||
(parsed as { themeMode?: unknown }).themeMode,
|
(parsed as { themeMode?: unknown }).themeMode,
|
||||||
);
|
);
|
||||||
const settings = {
|
const settings = {
|
||||||
gatewayUrl:
|
gatewayUrl,
|
||||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
|
||||||
? parsed.gatewayUrl.trim()
|
|
||||||
: defaults.gatewayUrl,
|
|
||||||
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
|
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
|
||||||
token: loadSessionToken(
|
token: loadSessionToken(gatewayUrl),
|
||||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
|
||||||
? parsed.gatewayUrl.trim()
|
|
||||||
: defaults.gatewayUrl,
|
|
||||||
),
|
|
||||||
sessionKey:
|
sessionKey:
|
||||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||||
? parsed.sessionKey.trim()
|
? parsed.sessionKey.trim()
|
||||||
|
|||||||
@@ -62,42 +62,13 @@ function resolveMode(mode: ThemeMode): "light" | "dark" {
|
|||||||
return mode;
|
return mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeThemeArgs(
|
export function resolveTheme(theme: ThemeName, mode: ThemeMode): ResolvedTheme {
|
||||||
themeOrMode: ThemeName | ThemeMode,
|
const resolvedMode = resolveMode(mode);
|
||||||
mode: ThemeMode | undefined,
|
if (theme === "claw") {
|
||||||
): { theme: ThemeName; mode: ThemeMode } {
|
|
||||||
if (VALID_THEME_NAMES.has(themeOrMode as ThemeName)) {
|
|
||||||
return {
|
|
||||||
theme: themeOrMode as ThemeName,
|
|
||||||
mode: mode ?? "system",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
theme: "claw",
|
|
||||||
mode: themeOrMode as ThemeMode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme;
|
|
||||||
export function resolveTheme(theme: ThemeName, mode?: ThemeMode): ResolvedTheme;
|
|
||||||
export function resolveTheme(themeOrMode: ThemeName | ThemeMode, mode?: ThemeMode): ResolvedTheme {
|
|
||||||
const normalized = normalizeThemeArgs(themeOrMode, mode);
|
|
||||||
const resolvedMode = resolveMode(normalized.mode);
|
|
||||||
if (normalized.theme === "claw") {
|
|
||||||
return resolvedMode === "light" ? "light" : "dark";
|
return resolvedMode === "light" ? "light" : "dark";
|
||||||
}
|
}
|
||||||
if (normalized.theme === "knot") {
|
if (theme === "knot") {
|
||||||
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
return resolvedMode === "light" ? "openknot-light" : "openknot";
|
||||||
}
|
}
|
||||||
return resolvedMode === "light" ? "dash-light" : "dash";
|
return resolvedMode === "light" ? "dash-light" : "dash";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function colorSchemeForTheme(theme: ResolvedTheme): "light" | "dark" {
|
|
||||||
return theme === "light" || theme === "openknot-light" || theme === "dash-light"
|
|
||||||
? "light"
|
|
||||||
: "dark";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dataThemeForTheme(theme: ResolvedTheme): ResolvedTheme | "light" {
|
|
||||||
return colorSchemeForTheme(theme) === "light" ? "light" : theme;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -411,6 +411,15 @@ export type {
|
|||||||
SessionUsageTimeSeries,
|
SessionUsageTimeSeries,
|
||||||
} from "./usage-types.ts";
|
} from "./usage-types.ts";
|
||||||
|
|
||||||
|
export type CronRunStatus = "ok" | "error" | "skipped";
|
||||||
|
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
|
||||||
|
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
|
||||||
|
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
|
||||||
|
export type CronRunScope = "job" | "all";
|
||||||
|
export type CronRunsStatusValue = CronRunStatus;
|
||||||
|
export type CronRunsStatusFilter = "all" | CronRunStatus;
|
||||||
|
export type CronSortDir = "asc" | "desc";
|
||||||
|
|
||||||
export type CronSchedule =
|
export type CronSchedule =
|
||||||
| { kind: "at"; at: string }
|
| { kind: "at"; at: string }
|
||||||
| { kind: "every"; everyMs: number; anchorMs?: number }
|
| { kind: "every"; everyMs: number; anchorMs?: number }
|
||||||
@@ -425,9 +434,15 @@ export type CronPayload =
|
|||||||
kind: "agentTurn";
|
kind: "agentTurn";
|
||||||
message: string;
|
message: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
fallbacks?: string[];
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
|
allowUnsafeExternalContent?: boolean;
|
||||||
lightContext?: boolean;
|
lightContext?: boolean;
|
||||||
|
deliver?: boolean;
|
||||||
|
channel?: string;
|
||||||
|
to?: string;
|
||||||
|
bestEffortDeliver?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronDelivery = {
|
export type CronDelivery = {
|
||||||
@@ -459,9 +474,15 @@ export type CronJobState = {
|
|||||||
nextRunAtMs?: number;
|
nextRunAtMs?: number;
|
||||||
runningAtMs?: number;
|
runningAtMs?: number;
|
||||||
lastRunAtMs?: number;
|
lastRunAtMs?: number;
|
||||||
lastStatus?: "ok" | "error" | "skipped";
|
lastRunStatus?: CronRunStatus;
|
||||||
|
lastStatus?: CronRunStatus;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
lastErrorReason?: string;
|
||||||
lastDurationMs?: number;
|
lastDurationMs?: number;
|
||||||
|
consecutiveErrors?: number;
|
||||||
|
lastDelivered?: boolean;
|
||||||
|
lastDeliveryStatus?: CronDeliveryStatus;
|
||||||
|
lastDeliveryError?: string;
|
||||||
lastFailureAlertAtMs?: number;
|
lastFailureAlertAtMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -482,25 +503,19 @@ export type CronStatus = {
|
|||||||
nextWakeAtMs?: number | null;
|
nextWakeAtMs?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
|
|
||||||
export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
|
|
||||||
export type CronSortDir = "asc" | "desc";
|
|
||||||
export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped";
|
|
||||||
export type CronRunsStatusValue = "ok" | "error" | "skipped";
|
|
||||||
export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested";
|
|
||||||
export type CronRunScope = "job" | "all";
|
|
||||||
|
|
||||||
export type CronRunLogEntry = {
|
export type CronRunLogEntry = {
|
||||||
ts: number;
|
ts: number;
|
||||||
jobId: string;
|
jobId: string;
|
||||||
jobName?: string;
|
action?: "finished";
|
||||||
status?: CronRunsStatusValue;
|
status?: CronRunStatus;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
delivered?: boolean;
|
||||||
deliveryStatus?: CronDeliveryStatus;
|
deliveryStatus?: CronDeliveryStatus;
|
||||||
deliveryError?: string;
|
deliveryError?: string;
|
||||||
delivered?: boolean;
|
sessionId?: string;
|
||||||
|
sessionKey?: string;
|
||||||
runAtMs?: number;
|
runAtMs?: number;
|
||||||
nextRunAtMs?: number;
|
nextRunAtMs?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -512,26 +527,25 @@ export type CronRunLogEntry = {
|
|||||||
cache_read_tokens?: number;
|
cache_read_tokens?: number;
|
||||||
cache_write_tokens?: number;
|
cache_write_tokens?: number;
|
||||||
};
|
};
|
||||||
sessionId?: string;
|
jobName?: string;
|
||||||
sessionKey?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronJobsListResult = {
|
export type CronJobsListResult = {
|
||||||
jobs?: CronJob[];
|
jobs: CronJob[];
|
||||||
total?: number;
|
total?: number;
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
hasMore?: boolean;
|
offset?: number;
|
||||||
nextOffset?: number | null;
|
nextOffset?: number | null;
|
||||||
|
hasMore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CronRunsResult = {
|
export type CronRunsResult = {
|
||||||
entries?: CronRunLogEntry[];
|
entries: CronRunLogEntry[];
|
||||||
total?: number;
|
total?: number;
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
limit?: number;
|
||||||
hasMore?: boolean;
|
offset?: number;
|
||||||
nextOffset?: number | null;
|
nextOffset?: number | null;
|
||||||
|
hasMore?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillsStatusConfigCheck = {
|
export type SkillsStatusConfigCheck = {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type ChatQueueItem = {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
refreshSessions?: boolean;
|
refreshSessions?: boolean;
|
||||||
|
localCommandArgs?: string;
|
||||||
|
localCommandName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CRON_CHANNEL_LAST = "last";
|
export const CRON_CHANNEL_LAST = "last";
|
||||||
|
|||||||
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 { html, nothing } from "lit";
|
||||||
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
import { formatRelativeTimestamp } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
|
import { icons } from "../icons.ts";
|
||||||
|
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||||
import {
|
import {
|
||||||
formatCronPayload,
|
formatCronPayload,
|
||||||
formatCronSchedule,
|
formatCronSchedule,
|
||||||
@@ -36,8 +39,8 @@ function renderAgentContextCard(context: AgentContext, subtitle: string) {
|
|||||||
<div>${context.identityName}</div>
|
<div>${context.identityName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-kv">
|
<div class="agent-kv">
|
||||||
<div class="label">Identity Emoji</div>
|
<div class="label">Identity Avatar</div>
|
||||||
<div>${context.identityEmoji}</div>
|
<div>${context.identityAvatar}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-kv">
|
<div class="agent-kv">
|
||||||
<div class="label">Skills Filter</div>
|
<div class="label">Skills Filter</div>
|
||||||
@@ -182,7 +185,7 @@ export function renderAgentChannels(params: {
|
|||||||
const status = summary.total
|
const status = summary.total
|
||||||
? `${summary.connected}/${summary.total} connected`
|
? `${summary.connected}/${summary.total} connected`
|
||||||
: "no accounts";
|
: "no accounts";
|
||||||
const config = summary.configured
|
const configLabel = summary.configured
|
||||||
? `${summary.configured} configured`
|
? `${summary.configured} configured`
|
||||||
: "not configured";
|
: "not configured";
|
||||||
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
|
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
|
||||||
@@ -199,8 +202,23 @@ export function renderAgentChannels(params: {
|
|||||||
</div>
|
</div>
|
||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
<div>${status}</div>
|
<div>${status}</div>
|
||||||
<div>${config}</div>
|
<div>${configLabel}</div>
|
||||||
<div>${enabled}</div>
|
<div>${enabled}</div>
|
||||||
|
${
|
||||||
|
summary.configured === 0
|
||||||
|
? html`
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="https://docs.openclaw.ai/channels"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
style="color: var(--accent); font-size: 12px"
|
||||||
|
>Setup guide</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
${
|
${
|
||||||
extras.length > 0
|
extras.length > 0
|
||||||
? extras.map(
|
? extras.map(
|
||||||
@@ -228,6 +246,7 @@ export function renderAgentCron(params: {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRunNow: (jobId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
|
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
|
||||||
return html`
|
return html`
|
||||||
@@ -297,6 +316,12 @@ export function renderAgentCron(params: {
|
|||||||
<div class="list-meta">
|
<div class="list-meta">
|
||||||
<div class="mono">${formatCronState(job)}</div>
|
<div class="mono">${formatCronState(job)}</div>
|
||||||
<div class="muted">${formatCronPayload(job)}</div>
|
<div class="muted">${formatCronPayload(job)}</div>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
style="margin-top: 6px;"
|
||||||
|
?disabled=${!job.enabled}
|
||||||
|
@click=${() => params.onRunNow(job.id)}
|
||||||
|
>Run Now</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
@@ -389,6 +414,21 @@ export function renderAgentFiles(params: {
|
|||||||
<div class="agent-file-sub mono">${activeEntry.path}</div>
|
<div class="agent-file-sub mono">${activeEntry.path}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-file-actions">
|
<div class="agent-file-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
title="Preview rendered markdown"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const dialog = btn
|
||||||
|
.closest(".agent-files-editor")
|
||||||
|
?.querySelector("dialog");
|
||||||
|
if (dialog) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${icons.eye} Preview
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm"
|
class="btn btn--sm"
|
||||||
?disabled=${!isDirty}
|
?disabled=${!isDirty}
|
||||||
@@ -414,9 +454,10 @@ export function renderAgentFiles(params: {
|
|||||||
`
|
`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
<label class="field" style="margin-top: 12px;">
|
<label class="field agent-file-field" style="margin-top: 12px;">
|
||||||
<span>Content</span>
|
<span>Content</span>
|
||||||
<textarea
|
<textarea
|
||||||
|
class="agent-file-textarea"
|
||||||
.value=${draft}
|
.value=${draft}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
params.onFileDraftChange(
|
params.onFileDraftChange(
|
||||||
@@ -425,6 +466,30 @@ export function renderAgentFiles(params: {
|
|||||||
)}
|
)}
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<dialog
|
||||||
|
class="md-preview-dialog"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
const dialog = e.currentTarget as HTMLDialogElement;
|
||||||
|
if (e.target === dialog) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="md-preview-dialog__panel">
|
||||||
|
<div class="md-preview-dialog__header">
|
||||||
|
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
@click=${(e: Event) => {
|
||||||
|
(e.currentTarget as HTMLElement).closest("dialog")?.close();
|
||||||
|
}}
|
||||||
|
>${icons.x} Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="md-preview-dialog__body sidebar-markdown">
|
||||||
|
${unsafeHTML(toSanitizedMarkdownHtml(draft))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { html, nothing } from "lit";
|
|||||||
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
|
import { normalizeToolName } from "../../../../src/agents/tool-policy-shared.js";
|
||||||
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
|
import type { SkillStatusEntry, SkillStatusReport, ToolsCatalogResult } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
|
type AgentToolEntry,
|
||||||
|
type AgentToolSection,
|
||||||
isAllowedByPolicy,
|
isAllowedByPolicy,
|
||||||
matchesList,
|
matchesList,
|
||||||
PROFILE_OPTIONS,
|
|
||||||
resolveAgentConfig,
|
resolveAgentConfig,
|
||||||
|
resolveToolProfileOptions,
|
||||||
resolveToolProfile,
|
resolveToolProfile,
|
||||||
TOOL_SECTIONS,
|
resolveToolSections,
|
||||||
} from "./agents-utils.ts";
|
} from "./agents-utils.ts";
|
||||||
import type { SkillGroup } from "./skills-grouping.ts";
|
import type { SkillGroup } from "./skills-grouping.ts";
|
||||||
import { groupSkills } from "./skills-grouping.ts";
|
import { groupSkills } from "./skills-grouping.ts";
|
||||||
@@ -17,6 +19,28 @@ import {
|
|||||||
renderSkillStatusChips,
|
renderSkillStatusChips,
|
||||||
} from "./skills-shared.ts";
|
} from "./skills-shared.ts";
|
||||||
|
|
||||||
|
function renderToolBadges(section: AgentToolSection, tool: AgentToolEntry) {
|
||||||
|
const source = tool.source ?? section.source;
|
||||||
|
const pluginId = tool.pluginId ?? section.pluginId;
|
||||||
|
const badges: string[] = [];
|
||||||
|
if (source === "plugin" && pluginId) {
|
||||||
|
badges.push(`plugin:${pluginId}`);
|
||||||
|
} else if (source === "core") {
|
||||||
|
badges.push("core");
|
||||||
|
}
|
||||||
|
if (tool.optional) {
|
||||||
|
badges.push("optional");
|
||||||
|
}
|
||||||
|
if (badges.length === 0) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
|
||||||
|
${badges.map((badge) => html`<span class="agent-pill">${badge}</span>`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderAgentTools(params: {
|
export function renderAgentTools(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
configForm: Record<string, unknown> | null;
|
configForm: Record<string, unknown> | null;
|
||||||
@@ -35,6 +59,8 @@ export function renderAgentTools(params: {
|
|||||||
const agentTools = config.entry?.tools ?? {};
|
const agentTools = config.entry?.tools ?? {};
|
||||||
const globalTools = config.globalTools ?? {};
|
const globalTools = config.globalTools ?? {};
|
||||||
const profile = agentTools.profile ?? globalTools.profile ?? "full";
|
const profile = agentTools.profile ?? globalTools.profile ?? "full";
|
||||||
|
const profileOptions = resolveToolProfileOptions(params.toolsCatalogResult);
|
||||||
|
const toolSections = resolveToolSections(params.toolsCatalogResult);
|
||||||
const profileSource = agentTools.profile
|
const profileSource = agentTools.profile
|
||||||
? "agent override"
|
? "agent override"
|
||||||
: globalTools.profile
|
: globalTools.profile
|
||||||
@@ -43,7 +69,11 @@ export function renderAgentTools(params: {
|
|||||||
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
|
const hasAgentAllow = Array.isArray(agentTools.allow) && agentTools.allow.length > 0;
|
||||||
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
|
const hasGlobalAllow = Array.isArray(globalTools.allow) && globalTools.allow.length > 0;
|
||||||
const editable =
|
const editable =
|
||||||
Boolean(params.configForm) && !params.configLoading && !params.configSaving && !hasAgentAllow;
|
Boolean(params.configForm) &&
|
||||||
|
!params.configLoading &&
|
||||||
|
!params.configSaving &&
|
||||||
|
!hasAgentAllow &&
|
||||||
|
!(params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError);
|
||||||
const alsoAllow = hasAgentAllow
|
const alsoAllow = hasAgentAllow
|
||||||
? []
|
? []
|
||||||
: Array.isArray(agentTools.alsoAllow)
|
: Array.isArray(agentTools.alsoAllow)
|
||||||
@@ -53,17 +83,7 @@ export function renderAgentTools(params: {
|
|||||||
const basePolicy = hasAgentAllow
|
const basePolicy = hasAgentAllow
|
||||||
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
|
? { allow: agentTools.allow ?? [], deny: agentTools.deny ?? [] }
|
||||||
: (resolveToolProfile(profile) ?? undefined);
|
: (resolveToolProfile(profile) ?? undefined);
|
||||||
const sections =
|
const toolIds = toolSections.flatMap((section) => section.tools.map((tool) => tool.id));
|
||||||
params.toolsCatalogResult?.groups?.length &&
|
|
||||||
params.toolsCatalogResult.agentId === params.agentId
|
|
||||||
? params.toolsCatalogResult.groups
|
|
||||||
: TOOL_SECTIONS;
|
|
||||||
const profileOptions =
|
|
||||||
params.toolsCatalogResult?.profiles?.length &&
|
|
||||||
params.toolsCatalogResult.agentId === params.agentId
|
|
||||||
? params.toolsCatalogResult.profiles
|
|
||||||
: PROFILE_OPTIONS;
|
|
||||||
const toolIds = sections.flatMap((section) => section.tools.map((tool) => tool.id));
|
|
||||||
|
|
||||||
const resolveAllowed = (toolId: string) => {
|
const resolveAllowed = (toolId: string) => {
|
||||||
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
|
const baseAllowed = isAllowedByPolicy(toolId, basePolicy);
|
||||||
@@ -152,15 +172,6 @@ export function renderAgentTools(params: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
|
||||||
params.toolsCatalogError
|
|
||||||
? html`
|
|
||||||
<div class="callout warn" style="margin-top: 12px">
|
|
||||||
Could not load runtime tool catalog. Showing fallback list.
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
${
|
${
|
||||||
!params.configForm
|
!params.configForm
|
||||||
? html`
|
? html`
|
||||||
@@ -188,6 +199,22 @@ export function renderAgentTools(params: {
|
|||||||
`
|
`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
|
${
|
||||||
|
params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError
|
||||||
|
? html`
|
||||||
|
<div class="callout info" style="margin-top: 12px">Loading runtime tool catalog…</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
${
|
||||||
|
params.toolsCatalogError
|
||||||
|
? html`
|
||||||
|
<div class="callout info" style="margin-top: 12px">
|
||||||
|
Could not load runtime tool catalog. Showing built-in fallback list instead.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
|
|
||||||
<div class="agent-tools-meta" style="margin-top: 16px;">
|
<div class="agent-tools-meta" style="margin-top: 16px;">
|
||||||
<div class="agent-kv">
|
<div class="agent-kv">
|
||||||
@@ -235,50 +262,27 @@ export function renderAgentTools(params: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="agent-tools-grid" style="margin-top: 20px;">
|
<div class="agent-tools-grid" style="margin-top: 20px;">
|
||||||
${sections.map(
|
${toolSections.map(
|
||||||
(section) =>
|
(section) =>
|
||||||
html`
|
html`
|
||||||
<div class="agent-tools-section">
|
<div class="agent-tools-section">
|
||||||
<div class="agent-tools-header">
|
<div class="agent-tools-header">
|
||||||
${section.label}
|
${section.label}
|
||||||
${
|
${
|
||||||
"source" in section && section.source === "plugin"
|
section.source === "plugin" && section.pluginId
|
||||||
? html`
|
? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
|
||||||
<span class="mono" style="margin-left: 6px">plugin</span>
|
|
||||||
`
|
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-tools-list">
|
<div class="agent-tools-list">
|
||||||
${section.tools.map((tool) => {
|
${section.tools.map((tool) => {
|
||||||
const { allowed } = resolveAllowed(tool.id);
|
const { allowed } = resolveAllowed(tool.id);
|
||||||
const catalogTool = tool as {
|
|
||||||
source?: "core" | "plugin";
|
|
||||||
pluginId?: string;
|
|
||||||
optional?: boolean;
|
|
||||||
};
|
|
||||||
const source =
|
|
||||||
catalogTool.source === "plugin"
|
|
||||||
? catalogTool.pluginId
|
|
||||||
? `plugin:${catalogTool.pluginId}`
|
|
||||||
: "plugin"
|
|
||||||
: "core";
|
|
||||||
const isOptional = catalogTool.optional === true;
|
|
||||||
return html`
|
return html`
|
||||||
<div class="agent-tool-row">
|
<div class="agent-tool-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="agent-tool-title mono">
|
<div class="agent-tool-title mono">${tool.label}</div>
|
||||||
${tool.label}
|
|
||||||
<span class="mono" style="margin-left: 8px; opacity: 0.8;">${source}</span>
|
|
||||||
${
|
|
||||||
isOptional
|
|
||||||
? html`
|
|
||||||
<span class="mono" style="margin-left: 6px; opacity: 0.8">optional</span>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="agent-tool-sub">${tool.description}</div>
|
<div class="agent-tool-sub">${tool.description}</div>
|
||||||
|
${renderToolBadges(section, tool)}
|
||||||
</div>
|
</div>
|
||||||
<label class="cfg-toggle">
|
<label class="cfg-toggle">
|
||||||
<input
|
<input
|
||||||
@@ -298,13 +302,6 @@ export function renderAgentTools(params: {
|
|||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
${
|
|
||||||
params.toolsCatalogLoading
|
|
||||||
? html`
|
|
||||||
<div class="card-sub" style="margin-top: 10px">Refreshing tool catalog…</div>
|
|
||||||
`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -361,9 +358,10 @@ export function renderAgentSkills(params: {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</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)}>
|
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => params.onClear(params.agentId)}>
|
||||||
Use All
|
Enable All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm"
|
class="btn btn--sm"
|
||||||
@@ -372,6 +370,15 @@ export function renderAgentSkills(params: {
|
|||||||
>
|
>
|
||||||
Disable All
|
Disable All
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
?disabled=${!editable || !usingAllowlist}
|
||||||
|
@click=${() => params.onClear(params.agentId)}
|
||||||
|
title="Remove per-agent allowlist and use all skills"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
|
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
|
||||||
Reload Config
|
Reload Config
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,18 +1,157 @@
|
|||||||
import { html } from "lit";
|
import { html } from "lit";
|
||||||
import {
|
|
||||||
listCoreToolSections,
|
|
||||||
PROFILE_OPTIONS as TOOL_PROFILE_OPTIONS,
|
|
||||||
} from "../../../../src/agents/tool-catalog.js";
|
|
||||||
import {
|
import {
|
||||||
expandToolGroups,
|
expandToolGroups,
|
||||||
normalizeToolName,
|
normalizeToolName,
|
||||||
resolveToolProfilePolicy,
|
resolveToolProfilePolicy,
|
||||||
} from "../../../../src/agents/tool-policy-shared.js";
|
} from "../../../../src/agents/tool-policy-shared.js";
|
||||||
import type { AgentIdentityResult, AgentsFilesListResult, AgentsListResult } from "../types.ts";
|
import type {
|
||||||
|
AgentIdentityResult,
|
||||||
|
AgentsFilesListResult,
|
||||||
|
AgentsListResult,
|
||||||
|
ToolCatalogProfile,
|
||||||
|
ToolsCatalogResult,
|
||||||
|
} from "../types.ts";
|
||||||
|
|
||||||
export const TOOL_SECTIONS = listCoreToolSections();
|
export type AgentToolEntry = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
source?: "core" | "plugin";
|
||||||
|
pluginId?: string;
|
||||||
|
optional?: boolean;
|
||||||
|
defaultProfiles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const PROFILE_OPTIONS = TOOL_PROFILE_OPTIONS;
|
export type AgentToolSection = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
source?: "core" | "plugin";
|
||||||
|
pluginId?: string;
|
||||||
|
tools: AgentToolEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FALLBACK_TOOL_SECTIONS: AgentToolSection[] = [
|
||||||
|
{
|
||||||
|
id: "fs",
|
||||||
|
label: "Files",
|
||||||
|
tools: [
|
||||||
|
{ id: "read", label: "read", description: "Read file contents" },
|
||||||
|
{ id: "write", label: "write", description: "Create or overwrite files" },
|
||||||
|
{ id: "edit", label: "edit", description: "Make precise edits" },
|
||||||
|
{ id: "apply_patch", label: "apply_patch", description: "Patch files (OpenAI)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "runtime",
|
||||||
|
label: "Runtime",
|
||||||
|
tools: [
|
||||||
|
{ id: "exec", label: "exec", description: "Run shell commands" },
|
||||||
|
{ id: "process", label: "process", description: "Manage background processes" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "web",
|
||||||
|
label: "Web",
|
||||||
|
tools: [
|
||||||
|
{ id: "web_search", label: "web_search", description: "Search the web" },
|
||||||
|
{ id: "web_fetch", label: "web_fetch", description: "Fetch web content" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
label: "Memory",
|
||||||
|
tools: [
|
||||||
|
{ id: "memory_search", label: "memory_search", description: "Semantic search" },
|
||||||
|
{ id: "memory_get", label: "memory_get", description: "Read memory files" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sessions",
|
||||||
|
label: "Sessions",
|
||||||
|
tools: [
|
||||||
|
{ id: "sessions_list", label: "sessions_list", description: "List sessions" },
|
||||||
|
{ id: "sessions_history", label: "sessions_history", description: "Session history" },
|
||||||
|
{ id: "sessions_send", label: "sessions_send", description: "Send to session" },
|
||||||
|
{ id: "sessions_spawn", label: "sessions_spawn", description: "Spawn sub-agent" },
|
||||||
|
{ id: "session_status", label: "session_status", description: "Session status" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ui",
|
||||||
|
label: "UI",
|
||||||
|
tools: [
|
||||||
|
{ id: "browser", label: "browser", description: "Control web browser" },
|
||||||
|
{ id: "canvas", label: "canvas", description: "Control canvases" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "messaging",
|
||||||
|
label: "Messaging",
|
||||||
|
tools: [{ id: "message", label: "message", description: "Send messages" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "automation",
|
||||||
|
label: "Automation",
|
||||||
|
tools: [
|
||||||
|
{ id: "cron", label: "cron", description: "Schedule tasks" },
|
||||||
|
{ id: "gateway", label: "gateway", description: "Gateway control" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nodes",
|
||||||
|
label: "Nodes",
|
||||||
|
tools: [{ id: "nodes", label: "nodes", description: "Nodes + devices" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "agents",
|
||||||
|
label: "Agents",
|
||||||
|
tools: [{ id: "agents_list", label: "agents_list", description: "List agents" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "media",
|
||||||
|
label: "Media",
|
||||||
|
tools: [{ id: "image", label: "image", description: "Image understanding" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PROFILE_OPTIONS = [
|
||||||
|
{ id: "minimal", label: "Minimal" },
|
||||||
|
{ id: "coding", label: "Coding" },
|
||||||
|
{ id: "messaging", label: "Messaging" },
|
||||||
|
{ id: "full", label: "Full" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function resolveToolSections(
|
||||||
|
toolsCatalogResult: ToolsCatalogResult | null,
|
||||||
|
): AgentToolSection[] {
|
||||||
|
if (toolsCatalogResult?.groups?.length) {
|
||||||
|
return toolsCatalogResult.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
source: group.source,
|
||||||
|
pluginId: group.pluginId,
|
||||||
|
tools: group.tools.map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
label: tool.label,
|
||||||
|
description: tool.description,
|
||||||
|
source: tool.source,
|
||||||
|
pluginId: tool.pluginId,
|
||||||
|
optional: tool.optional,
|
||||||
|
defaultProfiles: [...tool.defaultProfiles],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return FALLBACK_TOOL_SECTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveToolProfileOptions(
|
||||||
|
toolsCatalogResult: ToolsCatalogResult | null,
|
||||||
|
): readonly ToolCatalogProfile[] | typeof PROFILE_OPTIONS {
|
||||||
|
if (toolsCatalogResult?.profiles?.length) {
|
||||||
|
return toolsCatalogResult.profiles;
|
||||||
|
}
|
||||||
|
return PROFILE_OPTIONS;
|
||||||
|
}
|
||||||
|
|
||||||
type ToolPolicy = {
|
type ToolPolicy = {
|
||||||
allow?: string[];
|
allow?: string[];
|
||||||
@@ -55,6 +194,30 @@ export function normalizeAgentLabel(agent: {
|
|||||||
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
|
return agent.name?.trim() || agent.identity?.name?.trim() || agent.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AVATAR_URL_RE = /^(https?:\/\/|data:image\/|\/)/i;
|
||||||
|
|
||||||
|
export function resolveAgentAvatarUrl(
|
||||||
|
agent: { identity?: { avatar?: string; avatarUrl?: string } },
|
||||||
|
agentIdentity?: AgentIdentityResult | null,
|
||||||
|
): string | null {
|
||||||
|
const url =
|
||||||
|
agentIdentity?.avatar?.trim() ??
|
||||||
|
agent.identity?.avatarUrl?.trim() ??
|
||||||
|
agent.identity?.avatar?.trim();
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (AVATAR_URL_RE.test(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentLogoUrl(basePath: string): string {
|
||||||
|
const base = basePath?.trim() ? basePath.replace(/\/$/, "") : "";
|
||||||
|
return base ? `${base}/favicon.svg` : "/favicon.svg";
|
||||||
|
}
|
||||||
|
|
||||||
function isLikelyEmoji(value: string) {
|
function isLikelyEmoji(value: string) {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -106,6 +269,14 @@ export function agentBadgeText(agentId: string, defaultId: string | null) {
|
|||||||
return defaultId && agentId === defaultId ? "default" : null;
|
return defaultId && agentId === defaultId ? "default" : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function agentAvatarHue(id: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i += 1) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return ((hash % 360) + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes?: number) {
|
export function formatBytes(bytes?: number) {
|
||||||
if (bytes == null || !Number.isFinite(bytes)) {
|
if (bytes == null || !Number.isFinite(bytes)) {
|
||||||
return "-";
|
return "-";
|
||||||
@@ -138,7 +309,7 @@ export type AgentContext = {
|
|||||||
workspace: string;
|
workspace: string;
|
||||||
model: string;
|
model: string;
|
||||||
identityName: string;
|
identityName: string;
|
||||||
identityEmoji: string;
|
identityAvatar: string;
|
||||||
skillsLabel: string;
|
skillsLabel: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
@@ -164,14 +335,14 @@ export function buildAgentContext(
|
|||||||
agent.name?.trim() ||
|
agent.name?.trim() ||
|
||||||
config.entry?.name ||
|
config.entry?.name ||
|
||||||
agent.id;
|
agent.id;
|
||||||
const identityEmoji = resolveAgentEmoji(agent, agentIdentity) || "-";
|
const identityAvatar = resolveAgentAvatarUrl(agent, agentIdentity) ? "custom" : "—";
|
||||||
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
|
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
|
||||||
const skillCount = skillFilter?.length ?? null;
|
const skillCount = skillFilter?.length ?? null;
|
||||||
return {
|
return {
|
||||||
workspace,
|
workspace,
|
||||||
model: modelLabel,
|
model: modelLabel,
|
||||||
identityName,
|
identityName,
|
||||||
identityEmoji,
|
identityAvatar,
|
||||||
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
|
skillsLabel: skillFilter ? `${skillCount} selected` : "all skills",
|
||||||
isDefault: Boolean(defaultId && agent.id === defaultId),
|
isDefault: Boolean(defaultId && agent.id === defaultId),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,64 +9,78 @@ import type {
|
|||||||
SkillStatusReport,
|
SkillStatusReport,
|
||||||
ToolsCatalogResult,
|
ToolsCatalogResult,
|
||||||
} from "../types.ts";
|
} from "../types.ts";
|
||||||
|
import { renderAgentOverview } from "./agents-panels-overview.ts";
|
||||||
import {
|
import {
|
||||||
renderAgentFiles,
|
renderAgentFiles,
|
||||||
renderAgentChannels,
|
renderAgentChannels,
|
||||||
renderAgentCron,
|
renderAgentCron,
|
||||||
} from "./agents-panels-status-files.ts";
|
} from "./agents-panels-status-files.ts";
|
||||||
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
import { renderAgentTools, renderAgentSkills } from "./agents-panels-tools-skills.ts";
|
||||||
import {
|
import { agentBadgeText, buildAgentContext, normalizeAgentLabel } from "./agents-utils.ts";
|
||||||
agentBadgeText,
|
|
||||||
buildAgentContext,
|
|
||||||
buildModelOptions,
|
|
||||||
normalizeAgentLabel,
|
|
||||||
normalizeModelValue,
|
|
||||||
parseFallbackList,
|
|
||||||
resolveAgentConfig,
|
|
||||||
resolveAgentEmoji,
|
|
||||||
resolveEffectiveModelFallbacks,
|
|
||||||
resolveModelLabel,
|
|
||||||
resolveModelPrimary,
|
|
||||||
} from "./agents-utils.ts";
|
|
||||||
|
|
||||||
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
export type AgentsPanel = "overview" | "files" | "tools" | "skills" | "channels" | "cron";
|
||||||
|
|
||||||
|
export type ConfigState = {
|
||||||
|
form: Record<string, unknown> | null;
|
||||||
|
loading: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
dirty: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChannelsState = {
|
||||||
|
snapshot: ChannelsStatusSnapshot | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
lastSuccess: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CronState = {
|
||||||
|
status: CronStatus | null;
|
||||||
|
jobs: CronJob[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentFilesState = {
|
||||||
|
list: AgentsFilesListResult | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
active: string | null;
|
||||||
|
contents: Record<string, string>;
|
||||||
|
drafts: Record<string, string>;
|
||||||
|
saving: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentSkillsState = {
|
||||||
|
report: SkillStatusReport | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
agentId: string | null;
|
||||||
|
filter: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolsCatalogState = {
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
result: ToolsCatalogResult | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type AgentsProps = {
|
export type AgentsProps = {
|
||||||
|
basePath: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
agentsList: AgentsListResult | null;
|
agentsList: AgentsListResult | null;
|
||||||
selectedAgentId: string | null;
|
selectedAgentId: string | null;
|
||||||
activePanel: AgentsPanel;
|
activePanel: AgentsPanel;
|
||||||
configForm: Record<string, unknown> | null;
|
config: ConfigState;
|
||||||
configLoading: boolean;
|
channels: ChannelsState;
|
||||||
configSaving: boolean;
|
cron: CronState;
|
||||||
configDirty: boolean;
|
agentFiles: AgentFilesState;
|
||||||
channelsLoading: boolean;
|
|
||||||
channelsError: string | null;
|
|
||||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
|
||||||
channelsLastSuccess: number | null;
|
|
||||||
cronLoading: boolean;
|
|
||||||
cronStatus: CronStatus | null;
|
|
||||||
cronJobs: CronJob[];
|
|
||||||
cronError: string | null;
|
|
||||||
agentFilesLoading: boolean;
|
|
||||||
agentFilesError: string | null;
|
|
||||||
agentFilesList: AgentsFilesListResult | null;
|
|
||||||
agentFileActive: string | null;
|
|
||||||
agentFileContents: Record<string, string>;
|
|
||||||
agentFileDrafts: Record<string, string>;
|
|
||||||
agentFileSaving: boolean;
|
|
||||||
agentIdentityLoading: boolean;
|
agentIdentityLoading: boolean;
|
||||||
agentIdentityError: string | null;
|
agentIdentityError: string | null;
|
||||||
agentIdentityById: Record<string, AgentIdentityResult>;
|
agentIdentityById: Record<string, AgentIdentityResult>;
|
||||||
agentSkillsLoading: boolean;
|
agentSkills: AgentSkillsState;
|
||||||
agentSkillsReport: SkillStatusReport | null;
|
toolsCatalog: ToolsCatalogState;
|
||||||
agentSkillsError: string | null;
|
|
||||||
agentSkillsAgentId: string | null;
|
|
||||||
toolsCatalogLoading: boolean;
|
|
||||||
toolsCatalogError: string | null;
|
|
||||||
toolsCatalogResult: ToolsCatalogResult | null;
|
|
||||||
skillsFilter: string;
|
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onSelectAgent: (agentId: string) => void;
|
onSelectAgent: (agentId: string) => void;
|
||||||
onSelectPanel: (panel: AgentsPanel) => void;
|
onSelectPanel: (panel: AgentsPanel) => void;
|
||||||
@@ -83,20 +97,13 @@ export type AgentsProps = {
|
|||||||
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
||||||
onChannelsRefresh: () => void;
|
onChannelsRefresh: () => void;
|
||||||
onCronRefresh: () => void;
|
onCronRefresh: () => void;
|
||||||
|
onCronRunNow: (jobId: string) => void;
|
||||||
onSkillsFilterChange: (next: string) => void;
|
onSkillsFilterChange: (next: string) => void;
|
||||||
onSkillsRefresh: () => void;
|
onSkillsRefresh: () => void;
|
||||||
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
|
onAgentSkillToggle: (agentId: string, skillName: string, enabled: boolean) => void;
|
||||||
onAgentSkillsClear: (agentId: string) => void;
|
onAgentSkillsClear: (agentId: string) => void;
|
||||||
onAgentSkillsDisableAll: (agentId: string) => void;
|
onAgentSkillsDisableAll: (agentId: string) => void;
|
||||||
};
|
onSetDefault: (agentId: string) => void;
|
||||||
|
|
||||||
export type AgentContext = {
|
|
||||||
workspace: string;
|
|
||||||
model: string;
|
|
||||||
identityName: string;
|
|
||||||
identityEmoji: string;
|
|
||||||
skillsLabel: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderAgents(props: AgentsProps) {
|
export function renderAgents(props: AgentsProps) {
|
||||||
@@ -107,49 +114,96 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
? (agents.find((agent) => agent.id === selectedId) ?? null)
|
? (agents.find((agent) => agent.id === selectedId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const channelEntryCount = props.channels.snapshot
|
||||||
|
? Object.keys(props.channels.snapshot.channelAccounts ?? {}).length
|
||||||
|
: null;
|
||||||
|
const cronJobCount = selectedId
|
||||||
|
? props.cron.jobs.filter((j) => j.agentId === selectedId).length
|
||||||
|
: null;
|
||||||
|
const tabCounts: Record<string, number | null> = {
|
||||||
|
files: props.agentFiles.list?.files?.length ?? null,
|
||||||
|
skills: props.agentSkills.report?.skills?.length ?? null,
|
||||||
|
channels: channelEntryCount,
|
||||||
|
cron: cronJobCount || null,
|
||||||
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="agents-layout">
|
<div class="agents-layout">
|
||||||
<section class="card agents-sidebar">
|
<section class="agents-toolbar">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="agents-toolbar-row">
|
||||||
<div>
|
<span class="agents-toolbar-label">Agent</span>
|
||||||
<div class="card-title">Agents</div>
|
<div class="agents-control-row">
|
||||||
<div class="card-sub">${agents.length} configured.</div>
|
<div class="agents-control-select">
|
||||||
</div>
|
<select
|
||||||
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onRefresh}>
|
class="agents-select"
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
.value=${selectedId ?? ""}
|
||||||
</button>
|
?disabled=${props.loading || agents.length === 0}
|
||||||
</div>
|
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
|
||||||
${
|
>
|
||||||
props.error
|
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
|
||||||
: nothing
|
|
||||||
}
|
|
||||||
<div class="agent-list" style="margin-top: 12px;">
|
|
||||||
${
|
${
|
||||||
agents.length === 0
|
agents.length === 0
|
||||||
? html`
|
? html`
|
||||||
<div class="muted">No agents found.</div>
|
<option value="">No agents</option>
|
||||||
`
|
`
|
||||||
: agents.map((agent) => {
|
: agents.map(
|
||||||
const badge = agentBadgeText(agent.id, defaultId);
|
(agent) => html`
|
||||||
const emoji = resolveAgentEmoji(agent, props.agentIdentityById[agent.id] ?? null);
|
<option value=${agent.id} ?selected=${agent.id === selectedId}>
|
||||||
return html`
|
${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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="agent-row ${selectedId === agent.id ? "active" : ""}"
|
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
|
||||||
@click=${() => props.onSelectAgent(agent.id)}
|
@click=${() => {
|
||||||
|
props.onSetDefault(selectedAgent.id);
|
||||||
|
actionsMenuOpen = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div class="agent-avatar">${emoji || normalizeAgentLabel(agent).slice(0, 1)}</div>
|
${defaultId && selectedAgent.id === defaultId ? "Already default" : "Set as default"}
|
||||||
<div class="agent-info">
|
|
||||||
<div class="agent-title">${normalizeAgentLabel(agent)}</div>
|
|
||||||
<div class="agent-sub mono">${agent.id}</div>
|
|
||||||
</div>
|
|
||||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
</div>
|
||||||
})
|
`
|
||||||
|
: nothing
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
||||||
<section class="agents-main">
|
<section class="agents-main">
|
||||||
${
|
${
|
||||||
@@ -161,29 +215,26 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
${renderAgentHeader(
|
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
|
||||||
selectedAgent,
|
|
||||||
defaultId,
|
|
||||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
|
||||||
)}
|
|
||||||
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel))}
|
|
||||||
${
|
${
|
||||||
props.activePanel === "overview"
|
props.activePanel === "overview"
|
||||||
? renderAgentOverview({
|
? renderAgentOverview({
|
||||||
agent: selectedAgent,
|
agent: selectedAgent,
|
||||||
|
basePath: props.basePath,
|
||||||
defaultId,
|
defaultId,
|
||||||
configForm: props.configForm,
|
configForm: props.config.form,
|
||||||
agentFilesList: props.agentFilesList,
|
agentFilesList: props.agentFiles.list,
|
||||||
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
|
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
|
||||||
agentIdentityError: props.agentIdentityError,
|
agentIdentityError: props.agentIdentityError,
|
||||||
agentIdentityLoading: props.agentIdentityLoading,
|
agentIdentityLoading: props.agentIdentityLoading,
|
||||||
configLoading: props.configLoading,
|
configLoading: props.config.loading,
|
||||||
configSaving: props.configSaving,
|
configSaving: props.config.saving,
|
||||||
configDirty: props.configDirty,
|
configDirty: props.config.dirty,
|
||||||
onConfigReload: props.onConfigReload,
|
onConfigReload: props.onConfigReload,
|
||||||
onConfigSave: props.onConfigSave,
|
onConfigSave: props.onConfigSave,
|
||||||
onModelChange: props.onModelChange,
|
onModelChange: props.onModelChange,
|
||||||
onModelFallbacksChange: props.onModelFallbacksChange,
|
onModelFallbacksChange: props.onModelFallbacksChange,
|
||||||
|
onSelectPanel: props.onSelectPanel,
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
@@ -191,13 +242,13 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
props.activePanel === "files"
|
props.activePanel === "files"
|
||||||
? renderAgentFiles({
|
? renderAgentFiles({
|
||||||
agentId: selectedAgent.id,
|
agentId: selectedAgent.id,
|
||||||
agentFilesList: props.agentFilesList,
|
agentFilesList: props.agentFiles.list,
|
||||||
agentFilesLoading: props.agentFilesLoading,
|
agentFilesLoading: props.agentFiles.loading,
|
||||||
agentFilesError: props.agentFilesError,
|
agentFilesError: props.agentFiles.error,
|
||||||
agentFileActive: props.agentFileActive,
|
agentFileActive: props.agentFiles.active,
|
||||||
agentFileContents: props.agentFileContents,
|
agentFileContents: props.agentFiles.contents,
|
||||||
agentFileDrafts: props.agentFileDrafts,
|
agentFileDrafts: props.agentFiles.drafts,
|
||||||
agentFileSaving: props.agentFileSaving,
|
agentFileSaving: props.agentFiles.saving,
|
||||||
onLoadFiles: props.onLoadFiles,
|
onLoadFiles: props.onLoadFiles,
|
||||||
onSelectFile: props.onSelectFile,
|
onSelectFile: props.onSelectFile,
|
||||||
onFileDraftChange: props.onFileDraftChange,
|
onFileDraftChange: props.onFileDraftChange,
|
||||||
@@ -210,13 +261,13 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
props.activePanel === "tools"
|
props.activePanel === "tools"
|
||||||
? renderAgentTools({
|
? renderAgentTools({
|
||||||
agentId: selectedAgent.id,
|
agentId: selectedAgent.id,
|
||||||
configForm: props.configForm,
|
configForm: props.config.form,
|
||||||
configLoading: props.configLoading,
|
configLoading: props.config.loading,
|
||||||
configSaving: props.configSaving,
|
configSaving: props.config.saving,
|
||||||
configDirty: props.configDirty,
|
configDirty: props.config.dirty,
|
||||||
toolsCatalogLoading: props.toolsCatalogLoading,
|
toolsCatalogLoading: props.toolsCatalog.loading,
|
||||||
toolsCatalogError: props.toolsCatalogError,
|
toolsCatalogError: props.toolsCatalog.error,
|
||||||
toolsCatalogResult: props.toolsCatalogResult,
|
toolsCatalogResult: props.toolsCatalog.result,
|
||||||
onProfileChange: props.onToolsProfileChange,
|
onProfileChange: props.onToolsProfileChange,
|
||||||
onOverridesChange: props.onToolsOverridesChange,
|
onOverridesChange: props.onToolsOverridesChange,
|
||||||
onConfigReload: props.onConfigReload,
|
onConfigReload: props.onConfigReload,
|
||||||
@@ -228,15 +279,15 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
props.activePanel === "skills"
|
props.activePanel === "skills"
|
||||||
? renderAgentSkills({
|
? renderAgentSkills({
|
||||||
agentId: selectedAgent.id,
|
agentId: selectedAgent.id,
|
||||||
report: props.agentSkillsReport,
|
report: props.agentSkills.report,
|
||||||
loading: props.agentSkillsLoading,
|
loading: props.agentSkills.loading,
|
||||||
error: props.agentSkillsError,
|
error: props.agentSkills.error,
|
||||||
activeAgentId: props.agentSkillsAgentId,
|
activeAgentId: props.agentSkills.agentId,
|
||||||
configForm: props.configForm,
|
configForm: props.config.form,
|
||||||
configLoading: props.configLoading,
|
configLoading: props.config.loading,
|
||||||
configSaving: props.configSaving,
|
configSaving: props.config.saving,
|
||||||
configDirty: props.configDirty,
|
configDirty: props.config.dirty,
|
||||||
filter: props.skillsFilter,
|
filter: props.agentSkills.filter,
|
||||||
onFilterChange: props.onSkillsFilterChange,
|
onFilterChange: props.onSkillsFilterChange,
|
||||||
onRefresh: props.onSkillsRefresh,
|
onRefresh: props.onSkillsRefresh,
|
||||||
onToggle: props.onAgentSkillToggle,
|
onToggle: props.onAgentSkillToggle,
|
||||||
@@ -252,16 +303,16 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
? renderAgentChannels({
|
? renderAgentChannels({
|
||||||
context: buildAgentContext(
|
context: buildAgentContext(
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
props.configForm,
|
props.config.form,
|
||||||
props.agentFilesList,
|
props.agentFiles.list,
|
||||||
defaultId,
|
defaultId,
|
||||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||||
),
|
),
|
||||||
configForm: props.configForm,
|
configForm: props.config.form,
|
||||||
snapshot: props.channelsSnapshot,
|
snapshot: props.channels.snapshot,
|
||||||
loading: props.channelsLoading,
|
loading: props.channels.loading,
|
||||||
error: props.channelsError,
|
error: props.channels.error,
|
||||||
lastSuccess: props.channelsLastSuccess,
|
lastSuccess: props.channels.lastSuccess,
|
||||||
onRefresh: props.onChannelsRefresh,
|
onRefresh: props.onChannelsRefresh,
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
@@ -271,17 +322,18 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
? renderAgentCron({
|
? renderAgentCron({
|
||||||
context: buildAgentContext(
|
context: buildAgentContext(
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
props.configForm,
|
props.config.form,
|
||||||
props.agentFilesList,
|
props.agentFiles.list,
|
||||||
defaultId,
|
defaultId,
|
||||||
props.agentIdentityById[selectedAgent.id] ?? null,
|
props.agentIdentityById[selectedAgent.id] ?? null,
|
||||||
),
|
),
|
||||||
agentId: selectedAgent.id,
|
agentId: selectedAgent.id,
|
||||||
jobs: props.cronJobs,
|
jobs: props.cron.jobs,
|
||||||
status: props.cronStatus,
|
status: props.cron.status,
|
||||||
loading: props.cronLoading,
|
loading: props.cron.loading,
|
||||||
error: props.cronError,
|
error: props.cron.error,
|
||||||
onRefresh: props.onCronRefresh,
|
onRefresh: props.onCronRefresh,
|
||||||
|
onRunNow: props.onCronRunNow,
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
@@ -292,33 +344,13 @@ export function renderAgents(props: AgentsProps) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentHeader(
|
let actionsMenuOpen = false;
|
||||||
agent: AgentsListResult["agents"][number],
|
|
||||||
defaultId: string | null,
|
|
||||||
agentIdentity: AgentIdentityResult | null,
|
|
||||||
) {
|
|
||||||
const badge = agentBadgeText(agent.id, defaultId);
|
|
||||||
const displayName = normalizeAgentLabel(agent);
|
|
||||||
const subtitle = agent.identity?.theme?.trim() || "Agent workspace and routing.";
|
|
||||||
const emoji = resolveAgentEmoji(agent, agentIdentity);
|
|
||||||
return html`
|
|
||||||
<section class="card agent-header">
|
|
||||||
<div class="agent-header-main">
|
|
||||||
<div class="agent-avatar agent-avatar--lg">${emoji || displayName.slice(0, 1)}</div>
|
|
||||||
<div>
|
|
||||||
<div class="card-title">${displayName}</div>
|
|
||||||
<div class="card-sub">${subtitle}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-header-meta">
|
|
||||||
<div class="mono">${agent.id}</div>
|
|
||||||
${badge ? html`<span class="agent-pill">${badge}</span>` : nothing}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) => void) {
|
function renderAgentTabs(
|
||||||
|
active: AgentsPanel,
|
||||||
|
onSelect: (panel: AgentsPanel) => void,
|
||||||
|
counts: Record<string, number | null>,
|
||||||
|
) {
|
||||||
const tabs: Array<{ id: AgentsPanel; label: string }> = [
|
const tabs: Array<{ id: AgentsPanel; label: string }> = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "overview", label: "Overview" },
|
||||||
{ id: "files", label: "Files" },
|
{ id: "files", label: "Files" },
|
||||||
@@ -336,164 +368,10 @@ function renderAgentTabs(active: AgentsPanel, onSelect: (panel: AgentsPanel) =>
|
|||||||
type="button"
|
type="button"
|
||||||
@click=${() => onSelect(tab.id)}
|
@click=${() => onSelect(tab.id)}
|
||||||
>
|
>
|
||||||
${tab.label}
|
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
|
||||||
</button>
|
</button>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAgentOverview(params: {
|
|
||||||
agent: AgentsListResult["agents"][number];
|
|
||||||
defaultId: string | null;
|
|
||||||
configForm: Record<string, unknown> | null;
|
|
||||||
agentFilesList: AgentsFilesListResult | null;
|
|
||||||
agentIdentity: AgentIdentityResult | null;
|
|
||||||
agentIdentityLoading: boolean;
|
|
||||||
agentIdentityError: string | null;
|
|
||||||
configLoading: boolean;
|
|
||||||
configSaving: boolean;
|
|
||||||
configDirty: boolean;
|
|
||||||
onConfigReload: () => void;
|
|
||||||
onConfigSave: () => void;
|
|
||||||
onModelChange: (agentId: string, modelId: string | null) => void;
|
|
||||||
onModelFallbacksChange: (agentId: string, fallbacks: string[]) => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
agent,
|
|
||||||
configForm,
|
|
||||||
agentFilesList,
|
|
||||||
agentIdentity,
|
|
||||||
agentIdentityLoading,
|
|
||||||
agentIdentityError,
|
|
||||||
configLoading,
|
|
||||||
configSaving,
|
|
||||||
configDirty,
|
|
||||||
onConfigReload,
|
|
||||||
onConfigSave,
|
|
||||||
onModelChange,
|
|
||||||
onModelFallbacksChange,
|
|
||||||
} = params;
|
|
||||||
const config = resolveAgentConfig(configForm, agent.id);
|
|
||||||
const workspaceFromFiles =
|
|
||||||
agentFilesList && agentFilesList.agentId === agent.id ? agentFilesList.workspace : null;
|
|
||||||
const workspace =
|
|
||||||
workspaceFromFiles || config.entry?.workspace || config.defaults?.workspace || "default";
|
|
||||||
const model = config.entry?.model
|
|
||||||
? resolveModelLabel(config.entry?.model)
|
|
||||||
: resolveModelLabel(config.defaults?.model);
|
|
||||||
const defaultModel = resolveModelLabel(config.defaults?.model);
|
|
||||||
const modelPrimary =
|
|
||||||
resolveModelPrimary(config.entry?.model) || (model !== "-" ? normalizeModelValue(model) : null);
|
|
||||||
const defaultPrimary =
|
|
||||||
resolveModelPrimary(config.defaults?.model) ||
|
|
||||||
(defaultModel !== "-" ? normalizeModelValue(defaultModel) : null);
|
|
||||||
const effectivePrimary = modelPrimary ?? defaultPrimary ?? null;
|
|
||||||
const modelFallbacks = resolveEffectiveModelFallbacks(
|
|
||||||
config.entry?.model,
|
|
||||||
config.defaults?.model,
|
|
||||||
);
|
|
||||||
const fallbackText = modelFallbacks ? modelFallbacks.join(", ") : "";
|
|
||||||
const identityName =
|
|
||||||
agentIdentity?.name?.trim() ||
|
|
||||||
agent.identity?.name?.trim() ||
|
|
||||||
agent.name?.trim() ||
|
|
||||||
config.entry?.name ||
|
|
||||||
"-";
|
|
||||||
const resolvedEmoji = resolveAgentEmoji(agent, agentIdentity);
|
|
||||||
const identityEmoji = resolvedEmoji || "-";
|
|
||||||
const skillFilter = Array.isArray(config.entry?.skills) ? config.entry?.skills : null;
|
|
||||||
const skillCount = skillFilter?.length ?? null;
|
|
||||||
const identityStatus = agentIdentityLoading
|
|
||||||
? "Loading…"
|
|
||||||
: agentIdentityError
|
|
||||||
? "Unavailable"
|
|
||||||
: "";
|
|
||||||
const isDefault = Boolean(params.defaultId && agent.id === params.defaultId);
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-title">Overview</div>
|
|
||||||
<div class="card-sub">Workspace paths and identity metadata.</div>
|
|
||||||
<div class="agents-overview-grid" style="margin-top: 16px;">
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Workspace</div>
|
|
||||||
<div class="mono">${workspace}</div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Primary Model</div>
|
|
||||||
<div class="mono">${model}</div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Identity Name</div>
|
|
||||||
<div>${identityName}</div>
|
|
||||||
${identityStatus ? html`<div class="agent-kv-sub muted">${identityStatus}</div>` : nothing}
|
|
||||||
</div>
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Default</div>
|
|
||||||
<div>${isDefault ? "yes" : "no"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Identity Emoji</div>
|
|
||||||
<div>${identityEmoji}</div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-kv">
|
|
||||||
<div class="label">Skills Filter</div>
|
|
||||||
<div>${skillFilter ? `${skillCount} selected` : "all skills"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="agent-model-select" style="margin-top: 20px;">
|
|
||||||
<div class="label">Model Selection</div>
|
|
||||||
<div class="row" style="gap: 12px; flex-wrap: wrap;">
|
|
||||||
<label class="field" style="min-width: 260px; flex: 1;">
|
|
||||||
<span>Primary model${isDefault ? " (default)" : ""}</span>
|
|
||||||
<select
|
|
||||||
.value=${effectivePrimary ?? ""}
|
|
||||||
?disabled=${!configForm || configLoading || configSaving}
|
|
||||||
@change=${(e: Event) =>
|
|
||||||
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
|
|
||||||
>
|
|
||||||
${
|
|
||||||
isDefault
|
|
||||||
? nothing
|
|
||||||
: html`
|
|
||||||
<option value="">
|
|
||||||
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
|
|
||||||
</option>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
${buildModelOptions(configForm, effectivePrimary ?? undefined)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="field" style="min-width: 260px; flex: 1;">
|
|
||||||
<span>Fallbacks (comma-separated)</span>
|
|
||||||
<input
|
|
||||||
.value=${fallbackText}
|
|
||||||
?disabled=${!configForm || configLoading || configSaving}
|
|
||||||
placeholder="provider/model, provider/model"
|
|
||||||
@input=${(e: Event) =>
|
|
||||||
onModelFallbacksChange(
|
|
||||||
agent.id,
|
|
||||||
parseFallbackList((e.target as HTMLInputElement).value),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="justify-content: flex-end; gap: 8px;">
|
|
||||||
<button class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
|
|
||||||
Reload Config
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn--sm primary"
|
|
||||||
?disabled=${configSaving || !configDirty}
|
|
||||||
@click=${onConfigSave}
|
|
||||||
>
|
|
||||||
${configSaving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|||||||
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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primitiveTypes = new Set(["string", "number", "integer", "boolean"]);
|
const renderableUnionTypes = new Set([
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"integer",
|
||||||
|
"boolean",
|
||||||
|
"object",
|
||||||
|
"array",
|
||||||
|
]);
|
||||||
if (
|
if (
|
||||||
remaining.length > 0 &&
|
remaining.length > 0 &&
|
||||||
literals.length === 0 &&
|
literals.length === 0 &&
|
||||||
remaining.every((entry) => entry.type && primitiveTypes.has(String(entry.type)))
|
remaining.every((entry) => {
|
||||||
|
const type = schemaType(entry);
|
||||||
|
return Boolean(type) && renderableUnionTypes.has(String(type));
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
schema: {
|
schema: {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { html, nothing, type TemplateResult } from "lit";
|
import { html, nothing, type TemplateResult } from "lit";
|
||||||
|
import { icons as sharedIcons } from "../icons.ts";
|
||||||
import type { ConfigUiHints } from "../types.ts";
|
import type { ConfigUiHints } from "../types.ts";
|
||||||
import {
|
import {
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
hasSensitiveConfigData,
|
||||||
hintForPath,
|
hintForPath,
|
||||||
humanize,
|
humanize,
|
||||||
pathKey,
|
pathKey,
|
||||||
|
REDACTED_PLACEHOLDER,
|
||||||
schemaType,
|
schemaType,
|
||||||
type JsonSchema,
|
type JsonSchema,
|
||||||
} from "./config-form.shared.ts";
|
} from "./config-form.shared.ts";
|
||||||
@@ -100,11 +103,77 @@ type FieldMeta = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SensitiveRenderParams = {
|
||||||
|
path: Array<string | number>;
|
||||||
|
value: unknown;
|
||||||
|
hints: ConfigUiHints;
|
||||||
|
revealSensitive: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SensitiveRenderState = {
|
||||||
|
isSensitive: boolean;
|
||||||
|
isRedacted: boolean;
|
||||||
|
isRevealed: boolean;
|
||||||
|
canReveal: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ConfigSearchCriteria = {
|
export type ConfigSearchCriteria = {
|
||||||
text: string;
|
text: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
|
||||||
|
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
|
||||||
|
const isRevealed =
|
||||||
|
isSensitive &&
|
||||||
|
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
|
||||||
|
return {
|
||||||
|
isSensitive,
|
||||||
|
isRedacted: isSensitive && !isRevealed,
|
||||||
|
isRevealed,
|
||||||
|
canReveal: isSensitive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSensitiveToggleButton(params: {
|
||||||
|
path: Array<string | number>;
|
||||||
|
state: SensitiveRenderState;
|
||||||
|
disabled: boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
|
}): TemplateResult | typeof nothing {
|
||||||
|
const { state } = params;
|
||||||
|
if (!state.isSensitive || !params.onToggleSensitivePath) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
|
||||||
|
style="width:28px;height:28px;padding:0;"
|
||||||
|
title=${
|
||||||
|
state.canReveal
|
||||||
|
? state.isRevealed
|
||||||
|
? "Hide value"
|
||||||
|
: "Reveal value"
|
||||||
|
: "Disable stream mode to reveal value"
|
||||||
|
}
|
||||||
|
aria-label=${
|
||||||
|
state.canReveal
|
||||||
|
? state.isRevealed
|
||||||
|
? "Hide value"
|
||||||
|
: "Reveal value"
|
||||||
|
: "Disable stream mode to reveal value"
|
||||||
|
}
|
||||||
|
aria-pressed=${state.isRevealed}
|
||||||
|
?disabled=${params.disabled || !state.canReveal}
|
||||||
|
@click=${() => params.onToggleSensitivePath?.(params.path)}
|
||||||
|
>
|
||||||
|
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
|
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
|
||||||
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
|
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
|
||||||
}
|
}
|
||||||
@@ -331,6 +400,9 @@ export function renderNode(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
searchCriteria?: ConfigSearchCriteria;
|
searchCriteria?: ConfigSearchCriteria;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}): TemplateResult | typeof nothing {
|
}): TemplateResult | typeof nothing {
|
||||||
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
|
||||||
@@ -440,6 +512,20 @@ export function renderNode(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Complex union (e.g. array | object) — render as JSON textarea
|
||||||
|
return renderJsonTextarea({
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
hints,
|
||||||
|
disabled,
|
||||||
|
showLabel,
|
||||||
|
revealSensitive: params.revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||||
|
onPatch,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enum - use segmented for small, dropdown for large
|
// Enum - use segmented for small, dropdown for large
|
||||||
@@ -537,6 +623,9 @@ function renderTextInput(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
searchCriteria?: ConfigSearchCriteria;
|
searchCriteria?: ConfigSearchCriteria;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
inputType: "text" | "number";
|
inputType: "text" | "number";
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}): TemplateResult {
|
}): TemplateResult {
|
||||||
@@ -544,17 +633,22 @@ function renderTextInput(params: {
|
|||||||
const showLabel = params.showLabel ?? true;
|
const showLabel = params.showLabel ?? true;
|
||||||
const hint = hintForPath(path, hints);
|
const hint = hintForPath(path, hints);
|
||||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||||
const isSensitive =
|
const sensitiveState = getSensitiveRenderState({
|
||||||
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
|
path,
|
||||||
const placeholder =
|
value,
|
||||||
hint?.placeholder ??
|
hints,
|
||||||
|
revealSensitive: params.revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||||
|
});
|
||||||
|
const placeholder = sensitiveState.isRedacted
|
||||||
|
? REDACTED_PLACEHOLDER
|
||||||
|
: (hint?.placeholder ??
|
||||||
// oxlint-disable typescript/no-base-to-string
|
// oxlint-disable typescript/no-base-to-string
|
||||||
(isSensitive
|
(schema.default !== undefined ? `Default: ${String(schema.default)}` : ""));
|
||||||
? "••••"
|
const displayValue = sensitiveState.isRedacted ? "" : (value ?? "");
|
||||||
: schema.default !== undefined
|
const effectiveDisabled = disabled || sensitiveState.isRedacted;
|
||||||
? `Default: ${String(schema.default)}`
|
const effectiveInputType =
|
||||||
: "");
|
sensitiveState.isSensitive && !sensitiveState.isRedacted ? "text" : inputType;
|
||||||
const displayValue = value ?? "";
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
@@ -563,12 +657,16 @@ function renderTextInput(params: {
|
|||||||
${renderTags(tags)}
|
${renderTags(tags)}
|
||||||
<div class="cfg-input-wrap">
|
<div class="cfg-input-wrap">
|
||||||
<input
|
<input
|
||||||
type=${isSensitive ? "password" : inputType}
|
type=${effectiveInputType}
|
||||||
class="cfg-input"
|
class="cfg-input"
|
||||||
placeholder=${placeholder}
|
placeholder=${placeholder}
|
||||||
.value=${displayValue == null ? "" : String(displayValue)}
|
.value=${displayValue == null ? "" : String(displayValue)}
|
||||||
?disabled=${disabled}
|
?disabled=${effectiveDisabled}
|
||||||
|
?readonly=${sensitiveState.isRedacted}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: Event) => {
|
||||||
|
if (sensitiveState.isRedacted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const raw = (e.target as HTMLInputElement).value;
|
const raw = (e.target as HTMLInputElement).value;
|
||||||
if (inputType === "number") {
|
if (inputType === "number") {
|
||||||
if (raw.trim() === "") {
|
if (raw.trim() === "") {
|
||||||
@@ -582,13 +680,19 @@ function renderTextInput(params: {
|
|||||||
onPatch(path, raw);
|
onPatch(path, raw);
|
||||||
}}
|
}}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
if (inputType === "number") {
|
if (inputType === "number" || sensitiveState.isRedacted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = (e.target as HTMLInputElement).value;
|
const raw = (e.target as HTMLInputElement).value;
|
||||||
onPatch(path, raw.trim());
|
onPatch(path, raw.trim());
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
${renderSensitiveToggleButton({
|
||||||
|
path,
|
||||||
|
state: sensitiveState,
|
||||||
|
disabled,
|
||||||
|
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||||
|
})}
|
||||||
${
|
${
|
||||||
schema.default !== undefined
|
schema.default !== undefined
|
||||||
? html`
|
? html`
|
||||||
@@ -596,7 +700,7 @@ function renderTextInput(params: {
|
|||||||
type="button"
|
type="button"
|
||||||
class="cfg-input__reset"
|
class="cfg-input__reset"
|
||||||
title="Reset to default"
|
title="Reset to default"
|
||||||
?disabled=${disabled}
|
?disabled=${effectiveDisabled}
|
||||||
@click=${() => onPatch(path, schema.default)}
|
@click=${() => onPatch(path, schema.default)}
|
||||||
>↺</button>
|
>↺</button>
|
||||||
`
|
`
|
||||||
@@ -702,6 +806,73 @@ function renderSelect(params: {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderJsonTextarea(params: {
|
||||||
|
schema: JsonSchema;
|
||||||
|
value: unknown;
|
||||||
|
path: Array<string | number>;
|
||||||
|
hints: ConfigUiHints;
|
||||||
|
disabled: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
|
}): TemplateResult {
|
||||||
|
const { schema, value, path, hints, disabled, onPatch } = params;
|
||||||
|
const showLabel = params.showLabel ?? true;
|
||||||
|
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||||
|
const fallback = jsonValue(value);
|
||||||
|
const sensitiveState = getSensitiveRenderState({
|
||||||
|
path,
|
||||||
|
value,
|
||||||
|
hints,
|
||||||
|
revealSensitive: params.revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed: params.isSensitivePathRevealed,
|
||||||
|
});
|
||||||
|
const displayValue = sensitiveState.isRedacted ? "" : fallback;
|
||||||
|
const effectiveDisabled = disabled || sensitiveState.isRedacted;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="cfg-field">
|
||||||
|
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
|
||||||
|
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
|
||||||
|
${renderTags(tags)}
|
||||||
|
<div class="cfg-input-wrap">
|
||||||
|
<textarea
|
||||||
|
class="cfg-textarea"
|
||||||
|
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
|
||||||
|
rows="3"
|
||||||
|
.value=${displayValue}
|
||||||
|
?disabled=${effectiveDisabled}
|
||||||
|
?readonly=${sensitiveState.isRedacted}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
if (sensitiveState.isRedacted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
const raw = target.value.trim();
|
||||||
|
if (!raw) {
|
||||||
|
onPatch(path, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
onPatch(path, JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
target.value = fallback;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
${renderSensitiveToggleButton({
|
||||||
|
path,
|
||||||
|
state: sensitiveState,
|
||||||
|
disabled,
|
||||||
|
onToggleSensitivePath: params.onToggleSensitivePath,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderObject(params: {
|
function renderObject(params: {
|
||||||
schema: JsonSchema;
|
schema: JsonSchema;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
@@ -711,9 +882,24 @@ function renderObject(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
searchCriteria?: ConfigSearchCriteria;
|
searchCriteria?: ConfigSearchCriteria;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}): TemplateResult {
|
}): TemplateResult {
|
||||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
const {
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
hints,
|
||||||
|
unsupported,
|
||||||
|
disabled,
|
||||||
|
onPatch,
|
||||||
|
searchCriteria,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
|
} = params;
|
||||||
const showLabel = params.showLabel ?? true;
|
const showLabel = params.showLabel ?? true;
|
||||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||||
const selfMatched =
|
const selfMatched =
|
||||||
@@ -754,6 +940,9 @@ function renderObject(params: {
|
|||||||
unsupported,
|
unsupported,
|
||||||
disabled,
|
disabled,
|
||||||
searchCriteria: childSearchCriteria,
|
searchCriteria: childSearchCriteria,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
onPatch,
|
onPatch,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
@@ -768,6 +957,9 @@ function renderObject(params: {
|
|||||||
disabled,
|
disabled,
|
||||||
reservedKeys: reserved,
|
reservedKeys: reserved,
|
||||||
searchCriteria: childSearchCriteria,
|
searchCriteria: childSearchCriteria,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
onPatch,
|
onPatch,
|
||||||
})
|
})
|
||||||
: nothing
|
: nothing
|
||||||
@@ -818,9 +1010,24 @@ function renderArray(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
searchCriteria?: ConfigSearchCriteria;
|
searchCriteria?: ConfigSearchCriteria;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}): TemplateResult {
|
}): TemplateResult {
|
||||||
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
|
const {
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
hints,
|
||||||
|
unsupported,
|
||||||
|
disabled,
|
||||||
|
onPatch,
|
||||||
|
searchCriteria,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
|
} = params;
|
||||||
const showLabel = params.showLabel ?? true;
|
const showLabel = params.showLabel ?? true;
|
||||||
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
|
||||||
const selfMatched =
|
const selfMatched =
|
||||||
@@ -900,6 +1107,9 @@ function renderArray(params: {
|
|||||||
disabled,
|
disabled,
|
||||||
searchCriteria: childSearchCriteria,
|
searchCriteria: childSearchCriteria,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
onPatch,
|
onPatch,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -922,6 +1132,9 @@ function renderMapField(params: {
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
reservedKeys: Set<string>;
|
reservedKeys: Set<string>;
|
||||||
searchCriteria?: ConfigSearchCriteria;
|
searchCriteria?: ConfigSearchCriteria;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
}): TemplateResult {
|
}): TemplateResult {
|
||||||
const {
|
const {
|
||||||
@@ -934,6 +1147,9 @@ function renderMapField(params: {
|
|||||||
reservedKeys,
|
reservedKeys,
|
||||||
onPatch,
|
onPatch,
|
||||||
searchCriteria,
|
searchCriteria,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
} = params;
|
} = params;
|
||||||
const anySchema = isAnySchema(schema);
|
const anySchema = isAnySchema(schema);
|
||||||
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
|
||||||
@@ -985,6 +1201,13 @@ function renderMapField(params: {
|
|||||||
${visibleEntries.map(([key, entryValue]) => {
|
${visibleEntries.map(([key, entryValue]) => {
|
||||||
const valuePath = [...path, key];
|
const valuePath = [...path, key];
|
||||||
const fallback = jsonValue(entryValue);
|
const fallback = jsonValue(entryValue);
|
||||||
|
const sensitiveState = getSensitiveRenderState({
|
||||||
|
path: valuePath,
|
||||||
|
value: entryValue,
|
||||||
|
hints,
|
||||||
|
revealSensitive: revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
});
|
||||||
return html`
|
return html`
|
||||||
<div class="cfg-map__item">
|
<div class="cfg-map__item">
|
||||||
<div class="cfg-map__item-header">
|
<div class="cfg-map__item-header">
|
||||||
@@ -1028,13 +1251,20 @@ function renderMapField(params: {
|
|||||||
${
|
${
|
||||||
anySchema
|
anySchema
|
||||||
? html`
|
? html`
|
||||||
|
<div class="cfg-input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
class="cfg-textarea cfg-textarea--sm"
|
class="cfg-textarea cfg-textarea--sm"
|
||||||
placeholder="JSON value"
|
placeholder=${
|
||||||
|
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
|
||||||
|
}
|
||||||
rows="2"
|
rows="2"
|
||||||
.value=${fallback}
|
.value=${sensitiveState.isRedacted ? "" : fallback}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled || sensitiveState.isRedacted}
|
||||||
|
?readonly=${sensitiveState.isRedacted}
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
|
if (sensitiveState.isRedacted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const target = e.target as HTMLTextAreaElement;
|
const target = e.target as HTMLTextAreaElement;
|
||||||
const raw = target.value.trim();
|
const raw = target.value.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -1048,6 +1278,13 @@ function renderMapField(params: {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
${renderSensitiveToggleButton({
|
||||||
|
path: valuePath,
|
||||||
|
state: sensitiveState,
|
||||||
|
disabled,
|
||||||
|
onToggleSensitivePath,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: renderNode({
|
: renderNode({
|
||||||
schema,
|
schema,
|
||||||
@@ -1058,6 +1295,9 @@ function renderMapField(params: {
|
|||||||
disabled,
|
disabled,
|
||||||
searchCriteria,
|
searchCriteria,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
|
revealSensitive,
|
||||||
|
isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath,
|
||||||
onPatch,
|
onPatch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export type ConfigFormProps = {
|
|||||||
searchQuery?: string;
|
searchQuery?: string;
|
||||||
activeSection?: string | null;
|
activeSection?: string | null;
|
||||||
activeSubsection?: string | null;
|
activeSubsection?: string | null;
|
||||||
|
revealSensitive?: boolean;
|
||||||
|
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
|
||||||
|
onToggleSensitivePath?: (path: Array<string | number>) => void;
|
||||||
onPatch: (path: Array<string | number>, value: unknown) => void;
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -431,6 +434,9 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
disabled: props.disabled ?? false,
|
disabled: props.disabled ?? false,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
searchCriteria,
|
searchCriteria,
|
||||||
|
revealSensitive: props.revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||||
onPatch: props.onPatch,
|
onPatch: props.onPatch,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -466,6 +472,9 @@ export function renderConfigForm(props: ConfigFormProps) {
|
|||||||
disabled: props.disabled ?? false,
|
disabled: props.disabled ?? false,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
searchCriteria,
|
searchCriteria,
|
||||||
|
revealSensitive: props.revealSensitive ?? false,
|
||||||
|
isSensitivePathRevealed: props.isSensitivePathRevealed,
|
||||||
|
onToggleSensitivePath: props.onToggleSensitivePath,
|
||||||
onPatch: props.onPatch,
|
onPatch: props.onPatch,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ConfigUiHints } from "../types.ts";
|
import type { ConfigUiHint, ConfigUiHints } from "../types.ts";
|
||||||
|
|
||||||
export type JsonSchema = {
|
export type JsonSchema = {
|
||||||
type?: string | string[];
|
type?: string | string[];
|
||||||
@@ -94,3 +94,110 @@ export function humanize(raw: string) {
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.replace(/^./, (m) => m.toUpperCase());
|
.replace(/^./, (m) => m.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SENSITIVE_KEY_WHITELIST_SUFFIXES = [
|
||||||
|
"maxtokens",
|
||||||
|
"maxoutputtokens",
|
||||||
|
"maxinputtokens",
|
||||||
|
"maxcompletiontokens",
|
||||||
|
"contexttokens",
|
||||||
|
"totaltokens",
|
||||||
|
"tokencount",
|
||||||
|
"tokenlimit",
|
||||||
|
"tokenbudget",
|
||||||
|
"passwordfile",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SENSITIVE_PATTERNS = [
|
||||||
|
/token$/i,
|
||||||
|
/password/i,
|
||||||
|
/secret/i,
|
||||||
|
/api.?key/i,
|
||||||
|
/serviceaccount(?:ref)?$/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENV_VAR_PLACEHOLDER_PATTERN = /^\$\{[^}]*\}$/;
|
||||||
|
|
||||||
|
export const REDACTED_PLACEHOLDER = "[redacted - click reveal to view]";
|
||||||
|
|
||||||
|
function isEnvVarPlaceholder(value: string): boolean {
|
||||||
|
return ENV_VAR_PLACEHOLDER_PATTERN.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSensitiveConfigPath(path: string): boolean {
|
||||||
|
const lowerPath = path.toLowerCase();
|
||||||
|
const whitelisted = SENSITIVE_KEY_WHITELIST_SUFFIXES.some((suffix) => lowerPath.endsWith(suffix));
|
||||||
|
return !whitelisted && SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveLeafValue(value: unknown): boolean {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim().length > 0 && !isEnvVarPlaceholder(value);
|
||||||
|
}
|
||||||
|
return value !== undefined && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHintSensitive(hint: ConfigUiHint | undefined): boolean {
|
||||||
|
return hint?.sensitive ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSensitiveConfigData(
|
||||||
|
value: unknown,
|
||||||
|
path: Array<string | number>,
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
): boolean {
|
||||||
|
const key = pathKey(path);
|
||||||
|
const hint = hintForPath(path, hints);
|
||||||
|
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
|
||||||
|
|
||||||
|
if (pathIsSensitive && isSensitiveLeafValue(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.some((item, index) => hasSensitiveConfigData(item, [...path, index], hints));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.entries(value as Record<string, unknown>).some(([childKey, childValue]) =>
|
||||||
|
hasSensitiveConfigData(childValue, [...path, childKey], hints),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countSensitiveConfigValues(
|
||||||
|
value: unknown,
|
||||||
|
path: Array<string | number>,
|
||||||
|
hints: ConfigUiHints,
|
||||||
|
): number {
|
||||||
|
if (value == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = pathKey(path);
|
||||||
|
const hint = hintForPath(path, hints);
|
||||||
|
const pathIsSensitive = isHintSensitive(hint) || isSensitiveConfigPath(key);
|
||||||
|
|
||||||
|
if (pathIsSensitive && isSensitiveLeafValue(value)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.reduce(
|
||||||
|
(count, item, index) => count + countSensitiveConfigValues(item, [...path, index], hints),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return Object.entries(value as Record<string, unknown>).reduce(
|
||||||
|
(count, [childKey, childValue]) =>
|
||||||
|
count + countSensitiveConfigValues(childValue, [...path, childKey], hints),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -360,7 +360,9 @@ export function renderCron(props: CronProps) {
|
|||||||
props.runsScope === "all"
|
props.runsScope === "all"
|
||||||
? t("cron.jobList.allJobs")
|
? t("cron.jobList.allJobs")
|
||||||
: (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob"));
|
: (selectedJob?.name ?? props.runsJobId ?? t("cron.jobList.selectJob"));
|
||||||
const runs = props.runs;
|
const runs = props.runs.toSorted((a, b) =>
|
||||||
|
props.runsSortDir === "asc" ? a.ts - b.ts : b.ts - a.ts,
|
||||||
|
);
|
||||||
const runStatusOptions = getRunStatusOptions();
|
const runStatusOptions = getRunStatusOptions();
|
||||||
const runDeliveryOptions = getRunDeliveryOptions();
|
const runDeliveryOptions = getRunDeliveryOptions();
|
||||||
const selectedStatusLabels = runStatusOptions
|
const selectedStatusLabels = runStatusOptions
|
||||||
@@ -1569,7 +1571,7 @@ function renderJob(job: CronJob, props: CronProps) {
|
|||||||
?disabled=${props.busy}
|
?disabled=${props.busy}
|
||||||
@click=${(event: Event) => {
|
@click=${(event: Event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
selectAnd(() => props.onLoadRuns(job.id));
|
props.onLoadRuns(job.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
${t("cron.jobList.history")}
|
${t("cron.jobList.history")}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function renderDebug(props: DebugProps) {
|
|||||||
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
|
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { formatPresenceAge, formatPresenceSummary } from "../presenter.ts";
|
import { icons } from "../icons.ts";
|
||||||
|
import { formatPresenceAge } from "../presenter.ts";
|
||||||
import type { PresenceEntry } from "../types.ts";
|
import type { PresenceEntry } from "../types.ts";
|
||||||
|
|
||||||
export type InstancesProps = {
|
export type InstancesProps = {
|
||||||
@@ -10,7 +11,11 @@ export type InstancesProps = {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let hostsRevealed = false;
|
||||||
|
|
||||||
export function renderInstances(props: InstancesProps) {
|
export function renderInstances(props: InstancesProps) {
|
||||||
|
const masked = !hostsRevealed;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
@@ -18,10 +23,25 @@ export function renderInstances(props: InstancesProps) {
|
|||||||
<div class="card-title">Connected Instances</div>
|
<div class="card-title">Connected Instances</div>
|
||||||
<div class="card-sub">Presence beacons from the gateway and clients.</div>
|
<div class="card-sub">Presence beacons from the gateway and clients.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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}>
|
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
${
|
${
|
||||||
props.lastError
|
props.lastError
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">
|
? html`<div class="callout danger" style="margin-top: 12px;">
|
||||||
@@ -42,16 +62,18 @@ export function renderInstances(props: InstancesProps) {
|
|||||||
? html`
|
? html`
|
||||||
<div class="muted">No instances reported yet.</div>
|
<div class="muted">No instances reported yet.</div>
|
||||||
`
|
`
|
||||||
: props.entries.map((entry) => renderEntry(entry))
|
: props.entries.map((entry) => renderEntry(entry, masked))
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEntry(entry: PresenceEntry) {
|
function renderEntry(entry: PresenceEntry, masked: boolean) {
|
||||||
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
|
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
|
||||||
const mode = entry.mode ?? "unknown";
|
const mode = entry.mode ?? "unknown";
|
||||||
|
const host = entry.host ?? "unknown host";
|
||||||
|
const ip = entry.ip ?? null;
|
||||||
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
|
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
|
||||||
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
|
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
|
||||||
const scopesLabel =
|
const scopesLabel =
|
||||||
@@ -63,8 +85,12 @@ function renderEntry(entry: PresenceEntry) {
|
|||||||
return html`
|
return html`
|
||||||
<div class="list-item">
|
<div class="list-item">
|
||||||
<div class="list-main">
|
<div class="list-main">
|
||||||
<div class="list-title">${entry.host ?? "unknown host"}</div>
|
<div class="list-title">
|
||||||
<div class="list-sub">${formatPresenceSummary(entry)}</div>
|
<span class="${masked ? "redacted" : ""}">${host}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-sub">
|
||||||
|
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
|
||||||
|
</div>
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">${mode}</span>
|
<span class="chip">${mode}</span>
|
||||||
${roles.map((role) => html`<span class="chip">${role}</span>`)}
|
${roles.map((role) => html`<span class="chip">${role}</span>`)}
|
||||||
|
|||||||
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";
|
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
||||||
|
|
||||||
|
const AUTH_REQUIRED_CODES = new Set<string>([
|
||||||
|
ConnectErrorDetailCodes.AUTH_REQUIRED,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
|
||||||
|
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
|
||||||
|
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const AUTH_FAILURE_CODES = new Set<string>([
|
||||||
|
...AUTH_REQUIRED_CODES,
|
||||||
|
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
||||||
|
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
|
||||||
|
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
||||||
|
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
|
||||||
|
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INSECURE_CONTEXT_CODES = new Set<string>([
|
||||||
|
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
|
||||||
|
ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
/** Whether the overview should show device-pairing guidance for this error. */
|
/** Whether the overview should show device-pairing guidance for this error. */
|
||||||
export function shouldShowPairingHint(
|
export function shouldShowPairingHint(
|
||||||
connected: boolean,
|
connected: boolean,
|
||||||
@@ -14,3 +40,44 @@ export function shouldShowPairingHint(
|
|||||||
}
|
}
|
||||||
return lastError.toLowerCase().includes("pairing required");
|
return lastError.toLowerCase().includes("pairing required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldShowAuthHint(
|
||||||
|
connected: boolean,
|
||||||
|
lastError: string | null,
|
||||||
|
lastErrorCode?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (connected || !lastError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lastErrorCode) {
|
||||||
|
return AUTH_FAILURE_CODES.has(lastErrorCode);
|
||||||
|
}
|
||||||
|
const lower = lastError.toLowerCase();
|
||||||
|
return lower.includes("unauthorized") || lower.includes("connect failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowAuthRequiredHint(
|
||||||
|
hasToken: boolean,
|
||||||
|
hasPassword: boolean,
|
||||||
|
lastErrorCode?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (lastErrorCode) {
|
||||||
|
return AUTH_REQUIRED_CODES.has(lastErrorCode);
|
||||||
|
}
|
||||||
|
return !hasToken && !hasPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowInsecureContextHint(
|
||||||
|
connected: boolean,
|
||||||
|
lastError: string | null,
|
||||||
|
lastErrorCode?: string | null,
|
||||||
|
): boolean {
|
||||||
|
if (connected || !lastError) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lastErrorCode) {
|
||||||
|
return INSECURE_CONTEXT_CODES.has(lastErrorCode);
|
||||||
|
}
|
||||||
|
const lower = lastError.toLowerCase();
|
||||||
|
return lower.includes("secure context") || lower.includes("device identity required");
|
||||||
|
}
|
||||||
|
|||||||
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 { html, nothing } from "lit";
|
||||||
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
|
|
||||||
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
|
import { t, i18n, SUPPORTED_LOCALES, type Locale } from "../../i18n/index.ts";
|
||||||
|
import type { EventLogEntry } from "../app-events.ts";
|
||||||
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
|
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../external-link.ts";
|
||||||
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
import { formatRelativeTimestamp, formatDurationHuman } from "../format.ts";
|
||||||
import type { GatewayHelloOk } from "../gateway.ts";
|
import type { GatewayHelloOk } from "../gateway.ts";
|
||||||
import { formatNextRun } from "../presenter.ts";
|
import { icons } from "../icons.ts";
|
||||||
import type { UiSettings } from "../storage.ts";
|
import type { UiSettings } from "../storage.ts";
|
||||||
import { shouldShowPairingHint } from "./overview-hints.ts";
|
import type {
|
||||||
|
AttentionItem,
|
||||||
|
CronJob,
|
||||||
|
CronStatus,
|
||||||
|
SessionsListResult,
|
||||||
|
SessionsUsageResult,
|
||||||
|
SkillStatusReport,
|
||||||
|
} from "../types.ts";
|
||||||
|
import { renderOverviewAttention } from "./overview-attention.ts";
|
||||||
|
import { renderOverviewCards } from "./overview-cards.ts";
|
||||||
|
import { renderOverviewEventLog } from "./overview-event-log.ts";
|
||||||
|
import {
|
||||||
|
shouldShowAuthHint,
|
||||||
|
shouldShowAuthRequiredHint,
|
||||||
|
shouldShowInsecureContextHint,
|
||||||
|
shouldShowPairingHint,
|
||||||
|
} from "./overview-hints.ts";
|
||||||
|
import { renderOverviewLogTail } from "./overview-log-tail.ts";
|
||||||
|
|
||||||
export type OverviewProps = {
|
export type OverviewProps = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -20,24 +37,39 @@ export type OverviewProps = {
|
|||||||
cronEnabled: boolean | null;
|
cronEnabled: boolean | null;
|
||||||
cronNext: number | null;
|
cronNext: number | null;
|
||||||
lastChannelsRefresh: number | null;
|
lastChannelsRefresh: number | null;
|
||||||
|
// New dashboard data
|
||||||
|
usageResult: SessionsUsageResult | null;
|
||||||
|
sessionsResult: SessionsListResult | null;
|
||||||
|
skillsReport: SkillStatusReport | null;
|
||||||
|
cronJobs: CronJob[];
|
||||||
|
cronStatus: CronStatus | null;
|
||||||
|
attentionItems: AttentionItem[];
|
||||||
|
eventLog: EventLogEntry[];
|
||||||
|
overviewLogLines: string[];
|
||||||
|
showGatewayToken: boolean;
|
||||||
|
showGatewayPassword: boolean;
|
||||||
onSettingsChange: (next: UiSettings) => void;
|
onSettingsChange: (next: UiSettings) => void;
|
||||||
onPasswordChange: (next: string) => void;
|
onPasswordChange: (next: string) => void;
|
||||||
onSessionKeyChange: (next: string) => void;
|
onSessionKeyChange: (next: string) => void;
|
||||||
|
onToggleGatewayTokenVisibility: () => void;
|
||||||
|
onToggleGatewayPasswordVisibility: () => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onNavigate: (tab: string) => void;
|
||||||
|
onRefreshLogs: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderOverview(props: OverviewProps) {
|
export function renderOverview(props: OverviewProps) {
|
||||||
const snapshot = props.hello?.snapshot as
|
const snapshot = props.hello?.snapshot as
|
||||||
| {
|
| {
|
||||||
uptimeMs?: number;
|
uptimeMs?: number;
|
||||||
policy?: { tickIntervalMs?: number };
|
|
||||||
authMode?: "none" | "token" | "password" | "trusted-proxy";
|
authMode?: "none" | "token" | "password" | "trusted-proxy";
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
|
const uptime = snapshot?.uptimeMs ? formatDurationHuman(snapshot.uptimeMs) : t("common.na");
|
||||||
const tick = snapshot?.policy?.tickIntervalMs
|
const tickIntervalMs = props.hello?.policy?.tickIntervalMs;
|
||||||
? `${snapshot.policy.tickIntervalMs}ms`
|
const tick = tickIntervalMs
|
||||||
|
? `${(tickIntervalMs / 1000).toFixed(tickIntervalMs % 1000 === 0 ? 0 : 1)}s`
|
||||||
: t("common.na");
|
: t("common.na");
|
||||||
const authMode = snapshot?.authMode;
|
const authMode = snapshot?.authMode;
|
||||||
const isTrustedProxy = authMode === "trusted-proxy";
|
const isTrustedProxy = authMode === "trusted-proxy";
|
||||||
@@ -74,38 +106,12 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
if (props.connected || !props.lastError) {
|
if (props.connected || !props.lastError) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const lower = props.lastError.toLowerCase();
|
if (!shouldShowAuthHint(props.connected, props.lastError, props.lastErrorCode)) {
|
||||||
const authRequiredCodes = new Set<string>([
|
|
||||||
ConnectErrorDetailCodes.AUTH_REQUIRED,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISSING,
|
|
||||||
ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TOKEN_NOT_CONFIGURED,
|
|
||||||
ConnectErrorDetailCodes.AUTH_PASSWORD_NOT_CONFIGURED,
|
|
||||||
]);
|
|
||||||
const authFailureCodes = new Set<string>([
|
|
||||||
...authRequiredCodes,
|
|
||||||
ConnectErrorDetailCodes.AUTH_UNAUTHORIZED,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH,
|
|
||||||
ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH,
|
|
||||||
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
|
|
||||||
ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISSING,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_PROXY_MISSING,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_WHOIS_FAILED,
|
|
||||||
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
|
|
||||||
]);
|
|
||||||
const authFailed = props.lastErrorCode
|
|
||||||
? authFailureCodes.has(props.lastErrorCode)
|
|
||||||
: lower.includes("unauthorized") || lower.includes("connect failed");
|
|
||||||
if (!authFailed) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const hasToken = Boolean(props.settings.token.trim());
|
const hasToken = Boolean(props.settings.token.trim());
|
||||||
const hasPassword = Boolean(props.password.trim());
|
const hasPassword = Boolean(props.password.trim());
|
||||||
const isAuthRequired = props.lastErrorCode
|
if (shouldShowAuthRequiredHint(hasToken, hasPassword, props.lastErrorCode)) {
|
||||||
? authRequiredCodes.has(props.lastErrorCode)
|
|
||||||
: !hasToken && !hasPassword;
|
|
||||||
if (isAuthRequired) {
|
|
||||||
return html`
|
return html`
|
||||||
<div class="muted" style="margin-top: 8px">
|
<div class="muted" style="margin-top: 8px">
|
||||||
${t("overview.auth.required")}
|
${t("overview.auth.required")}
|
||||||
@@ -151,15 +157,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
if (isSecureContext) {
|
if (isSecureContext) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const lower = props.lastError.toLowerCase();
|
if (!shouldShowInsecureContextHint(props.connected, props.lastError, props.lastErrorCode)) {
|
||||||
const insecureContextCode =
|
|
||||||
props.lastErrorCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
|
|
||||||
props.lastErrorCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED;
|
|
||||||
if (
|
|
||||||
!insecureContextCode &&
|
|
||||||
!lower.includes("secure context") &&
|
|
||||||
!lower.includes("device identity required")
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return html`
|
return html`
|
||||||
@@ -194,12 +192,12 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
const currentLocale = i18n.getLocale();
|
const currentLocale = i18n.getLocale();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-title">${t("overview.access.title")}</div>
|
<div class="card-title">${t("overview.access.title")}</div>
|
||||||
<div class="card-sub">${t("overview.access.subtitle")}</div>
|
<div class="card-sub">${t("overview.access.subtitle")}</div>
|
||||||
<div class="form-grid" style="margin-top: 16px;">
|
<div class="ov-access-grid" style="margin-top: 16px;">
|
||||||
<label class="field">
|
<label class="field ov-access-grid__full">
|
||||||
<span>${t("overview.access.wsUrl")}</span>
|
<span>${t("overview.access.wsUrl")}</span>
|
||||||
<input
|
<input
|
||||||
.value=${props.settings.gatewayUrl}
|
.value=${props.settings.gatewayUrl}
|
||||||
@@ -220,7 +218,11 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
: html`
|
: html`
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${t("overview.access.token")}</span>
|
<span>${t("overview.access.token")}</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<input
|
<input
|
||||||
|
type=${props.showGatewayToken ? "text" : "password"}
|
||||||
|
autocomplete="off"
|
||||||
|
style="flex: 1;"
|
||||||
.value=${props.settings.token}
|
.value=${props.settings.token}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: Event) => {
|
||||||
const v = (e.target as HTMLInputElement).value;
|
const v = (e.target as HTMLInputElement).value;
|
||||||
@@ -228,11 +230,26 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
}}
|
}}
|
||||||
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
placeholder="OPENCLAW_GATEWAY_TOKEN"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--icon ${props.showGatewayToken ? "active" : ""}"
|
||||||
|
style="width: 36px; height: 36px;"
|
||||||
|
title=${props.showGatewayToken ? "Hide token" : "Show token"}
|
||||||
|
aria-label="Toggle token visibility"
|
||||||
|
aria-pressed=${props.showGatewayToken}
|
||||||
|
@click=${props.onToggleGatewayTokenVisibility}
|
||||||
|
>
|
||||||
|
${props.showGatewayToken ? icons.eye : icons.eyeOff}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>${t("overview.access.password")}</span>
|
<span>${t("overview.access.password")}</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type=${props.showGatewayPassword ? "text" : "password"}
|
||||||
|
autocomplete="off"
|
||||||
|
style="flex: 1;"
|
||||||
.value=${props.password}
|
.value=${props.password}
|
||||||
@input=${(e: Event) => {
|
@input=${(e: Event) => {
|
||||||
const v = (e.target as HTMLInputElement).value;
|
const v = (e.target as HTMLInputElement).value;
|
||||||
@@ -240,6 +257,18 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
}}
|
}}
|
||||||
placeholder="system or shared password"
|
placeholder="system or shared password"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn--icon ${props.showGatewayPassword ? "active" : ""}"
|
||||||
|
style="width: 36px; height: 36px;"
|
||||||
|
title=${props.showGatewayPassword ? "Hide password" : "Show password"}
|
||||||
|
aria-label="Toggle password visibility"
|
||||||
|
aria-pressed=${props.showGatewayPassword}
|
||||||
|
@click=${props.onToggleGatewayPasswordVisibility}
|
||||||
|
>
|
||||||
|
${props.showGatewayPassword ? icons.eye : icons.eyeOff}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -277,6 +306,30 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
|
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
|
||||||
}</span>
|
}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${
|
||||||
|
!props.connected
|
||||||
|
? html`
|
||||||
|
<div class="login-gate__help" style="margin-top: 16px;">
|
||||||
|
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
|
||||||
|
<ol class="login-gate__steps">
|
||||||
|
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
|
||||||
|
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
|
||||||
|
<li>${t("overview.connection.step3")}</li>
|
||||||
|
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
|
||||||
|
</ol>
|
||||||
|
<div class="login-gate__docs">
|
||||||
|
${t("overview.connection.docsHint")}
|
||||||
|
<a
|
||||||
|
class="session-link"
|
||||||
|
href="https://docs.openclaw.ai/web/dashboard"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>${t("overview.connection.docsLink")}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -321,45 +374,32 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid grid-cols-3" style="margin-top: 18px;">
|
<div class="ov-section-divider"></div>
|
||||||
<div class="card stat-card">
|
|
||||||
<div class="stat-label">${t("overview.stats.instances")}</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="card" style="margin-top: 18px;">
|
${renderOverviewCards({
|
||||||
<div class="card-title">${t("overview.notes.title")}</div>
|
usageResult: props.usageResult,
|
||||||
<div class="card-sub">${t("overview.notes.subtitle")}</div>
|
sessionsResult: props.sessionsResult,
|
||||||
<div class="note-grid" style="margin-top: 14px;">
|
skillsReport: props.skillsReport,
|
||||||
<div>
|
cronJobs: props.cronJobs,
|
||||||
<div class="note-title">${t("overview.notes.tailscaleTitle")}</div>
|
cronStatus: props.cronStatus,
|
||||||
<div class="muted">
|
presenceCount: props.presenceCount,
|
||||||
${t("overview.notes.tailscaleText")}
|
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>
|
|
||||||
<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 { html, nothing } from "lit";
|
||||||
import { formatRelativeTimestamp } from "../format.ts";
|
import { formatRelativeTimestamp } from "../format.ts";
|
||||||
|
import { icons } from "../icons.ts";
|
||||||
import { pathForTab } from "../navigation.ts";
|
import { pathForTab } from "../navigation.ts";
|
||||||
import { formatSessionTokens } from "../presenter.ts";
|
import { formatSessionTokens } from "../presenter.ts";
|
||||||
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
import type { GatewaySessionRow, SessionsListResult } from "../types.ts";
|
||||||
@@ -13,12 +14,23 @@ export type SessionsProps = {
|
|||||||
includeGlobal: boolean;
|
includeGlobal: boolean;
|
||||||
includeUnknown: boolean;
|
includeUnknown: boolean;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
|
searchQuery: string;
|
||||||
|
sortColumn: "key" | "kind" | "updated" | "tokens";
|
||||||
|
sortDir: "asc" | "desc";
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
actionsOpenKey: string | null;
|
||||||
onFiltersChange: (next: {
|
onFiltersChange: (next: {
|
||||||
activeMinutes: string;
|
activeMinutes: string;
|
||||||
limit: string;
|
limit: string;
|
||||||
includeGlobal: boolean;
|
includeGlobal: boolean;
|
||||||
includeUnknown: boolean;
|
includeUnknown: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
onActionsOpenChange: (key: string | null) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onPatch: (
|
onPatch: (
|
||||||
key: string,
|
key: string,
|
||||||
@@ -41,6 +53,7 @@ const VERBOSE_LEVELS = [
|
|||||||
{ value: "full", label: "full" },
|
{ value: "full", label: "full" },
|
||||||
] as const;
|
] as const;
|
||||||
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
const REASONING_LEVELS = ["", "off", "on", "stream"] as const;
|
||||||
|
const PAGE_SIZES = [10, 25, 50, 100] as const;
|
||||||
|
|
||||||
function normalizeProviderId(provider?: string | null): string {
|
function normalizeProviderId(provider?: string | null): string {
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
@@ -107,24 +120,110 @@ function resolveThinkLevelPatchValue(value: string, isBinary: boolean): string |
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterRows(rows: GatewaySessionRow[], query: string): GatewaySessionRow[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((row) => {
|
||||||
|
const key = (row.key ?? "").toLowerCase();
|
||||||
|
const label = (row.label ?? "").toLowerCase();
|
||||||
|
const kind = (row.kind ?? "").toLowerCase();
|
||||||
|
const displayName = (row.displayName ?? "").toLowerCase();
|
||||||
|
return key.includes(q) || label.includes(q) || kind.includes(q) || displayName.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(
|
||||||
|
rows: GatewaySessionRow[],
|
||||||
|
column: "key" | "kind" | "updated" | "tokens",
|
||||||
|
dir: "asc" | "desc",
|
||||||
|
): GatewaySessionRow[] {
|
||||||
|
const cmp = dir === "asc" ? 1 : -1;
|
||||||
|
return [...rows].toSorted((a, b) => {
|
||||||
|
let diff = 0;
|
||||||
|
switch (column) {
|
||||||
|
case "key":
|
||||||
|
diff = (a.key ?? "").localeCompare(b.key ?? "");
|
||||||
|
break;
|
||||||
|
case "kind":
|
||||||
|
diff = (a.kind ?? "").localeCompare(b.kind ?? "");
|
||||||
|
break;
|
||||||
|
case "updated": {
|
||||||
|
const au = a.updatedAt ?? 0;
|
||||||
|
const bu = b.updatedAt ?? 0;
|
||||||
|
diff = au - bu;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tokens": {
|
||||||
|
const at = a.totalTokens ?? a.inputTokens ?? a.outputTokens ?? 0;
|
||||||
|
const bt = b.totalTokens ?? b.inputTokens ?? b.outputTokens ?? 0;
|
||||||
|
diff = at - bt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff * cmp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
|
||||||
|
const start = page * pageSize;
|
||||||
|
return rows.slice(start, start + pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderSessions(props: SessionsProps) {
|
export function renderSessions(props: SessionsProps) {
|
||||||
const rows = props.result?.sessions ?? [];
|
const rawRows = props.result?.sessions ?? [];
|
||||||
|
const filtered = filterRows(rawRows, props.searchQuery);
|
||||||
|
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
|
||||||
|
const totalRows = sorted.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
|
||||||
|
const page = Math.min(props.page, totalPages - 1);
|
||||||
|
const paginated = paginateRows(sorted, page, props.pageSize);
|
||||||
|
|
||||||
|
const sortHeader = (col: "key" | "kind" | "updated" | "tokens", label: string) => {
|
||||||
|
const isActive = props.sortColumn === col;
|
||||||
|
const nextDir = isActive && props.sortDir === "asc" ? ("desc" as const) : ("asc" as const);
|
||||||
return html`
|
return html`
|
||||||
<section class="card">
|
<th
|
||||||
<div class="row" style="justify-content: space-between;">
|
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>
|
||||||
<div class="card-title">Sessions</div>
|
<div class="card-title">Sessions</div>
|
||||||
<div class="card-sub">Active session keys and per-session overrides.</div>
|
<div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 14px;">
|
<div class="filters" style="margin-bottom: 12px;">
|
||||||
<label class="field">
|
<label class="field-inline">
|
||||||
<span>Active within (minutes)</span>
|
<span>Active</span>
|
||||||
<input
|
<input
|
||||||
|
style="width: 72px;"
|
||||||
|
placeholder="min"
|
||||||
.value=${props.activeMinutes}
|
.value=${props.activeMinutes}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFiltersChange({
|
props.onFiltersChange({
|
||||||
@@ -135,9 +234,10 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field-inline">
|
||||||
<span>Limit</span>
|
<span>Limit</span>
|
||||||
<input
|
<input
|
||||||
|
style="width: 64px;"
|
||||||
.value=${props.limit}
|
.value=${props.limit}
|
||||||
@input=${(e: Event) =>
|
@input=${(e: Event) =>
|
||||||
props.onFiltersChange({
|
props.onFiltersChange({
|
||||||
@@ -148,8 +248,7 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field checkbox">
|
<label class="field-inline checkbox">
|
||||||
<span>Include global</span>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
.checked=${props.includeGlobal}
|
.checked=${props.includeGlobal}
|
||||||
@@ -161,9 +260,9 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
includeUnknown: props.includeUnknown,
|
includeUnknown: props.includeUnknown,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<span>Global</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="field checkbox">
|
<label class="field-inline checkbox">
|
||||||
<span>Include unknown</span>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
.checked=${props.includeUnknown}
|
.checked=${props.includeUnknown}
|
||||||
@@ -175,40 +274,103 @@ export function renderSessions(props: SessionsProps) {
|
|||||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
<span>Unknown</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
${
|
||||||
props.error
|
props.error
|
||||||
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
|
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
|
||||||
: nothing
|
: nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="muted" style="margin-top: 12px;">
|
<div class="data-table-wrapper">
|
||||||
${props.result ? `Store: ${props.result.path}` : ""}
|
<div class="data-table-toolbar">
|
||||||
|
<div 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>
|
||||||
|
|
||||||
<div class="table" style="margin-top: 16px;">
|
<div class="data-table-container">
|
||||||
<div class="table-head">
|
<table class="data-table">
|
||||||
<div>Key</div>
|
<thead>
|
||||||
<div>Label</div>
|
<tr>
|
||||||
<div>Kind</div>
|
${sortHeader("key", "Key")}
|
||||||
<div>Updated</div>
|
<th>Label</th>
|
||||||
<div>Tokens</div>
|
${sortHeader("kind", "Kind")}
|
||||||
<div>Thinking</div>
|
${sortHeader("updated", "Updated")}
|
||||||
<div>Verbose</div>
|
${sortHeader("tokens", "Tokens")}
|
||||||
<div>Reasoning</div>
|
<th>Thinking</th>
|
||||||
<div>Actions</div>
|
<th>Verbose</th>
|
||||||
</div>
|
<th>Reasoning</th>
|
||||||
|
<th style="width: 60px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
${
|
${
|
||||||
rows.length === 0
|
paginated.length === 0
|
||||||
? html`
|
? 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) =>
|
: paginated.map((row) =>
|
||||||
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`;
|
`;
|
||||||
@@ -219,6 +381,8 @@ function renderRow(
|
|||||||
basePath: string,
|
basePath: string,
|
||||||
onPatch: SessionsProps["onPatch"],
|
onPatch: SessionsProps["onPatch"],
|
||||||
onDelete: SessionsProps["onDelete"],
|
onDelete: SessionsProps["onDelete"],
|
||||||
|
onActionsOpenChange: (key: string | null) => void,
|
||||||
|
actionsOpenKey: string | null,
|
||||||
disabled: boolean,
|
disabled: boolean,
|
||||||
) {
|
) {
|
||||||
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : "n/a";
|
||||||
@@ -234,36 +398,58 @@ function renderRow(
|
|||||||
typeof row.displayName === "string" && row.displayName.trim().length > 0
|
typeof row.displayName === "string" && row.displayName.trim().length > 0
|
||||||
? row.displayName.trim()
|
? row.displayName.trim()
|
||||||
: null;
|
: null;
|
||||||
const label = typeof row.label === "string" ? row.label.trim() : "";
|
const showDisplayName = Boolean(
|
||||||
const showDisplayName = Boolean(displayName && displayName !== row.key && displayName !== label);
|
displayName &&
|
||||||
|
displayName !== row.key &&
|
||||||
|
displayName !== (typeof row.label === "string" ? row.label.trim() : ""),
|
||||||
|
);
|
||||||
const canLink = row.kind !== "global";
|
const canLink = row.kind !== "global";
|
||||||
const chatUrl = canLink
|
const chatUrl = canLink
|
||||||
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}`
|
||||||
: null;
|
: null;
|
||||||
|
const isMenuOpen = actionsOpenKey === row.key;
|
||||||
|
const badgeClass =
|
||||||
|
row.kind === "direct"
|
||||||
|
? "data-table-badge--direct"
|
||||||
|
: row.kind === "group"
|
||||||
|
? "data-table-badge--group"
|
||||||
|
: row.kind === "global"
|
||||||
|
? "data-table-badge--global"
|
||||||
|
: "data-table-badge--unknown";
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="table-row">
|
<tr>
|
||||||
|
<td>
|
||||||
<div class="mono session-key-cell">
|
<div class="mono session-key-cell">
|
||||||
${canLink ? html`<a href=${chatUrl} class="session-link">${row.key}</a>` : row.key}
|
${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>
|
||||||
<div>
|
</td>
|
||||||
|
<td>
|
||||||
<input
|
<input
|
||||||
.value=${row.label ?? ""}
|
.value=${row.label ?? ""}
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
placeholder="(optional)"
|
placeholder="(optional)"
|
||||||
|
style="width: 100%; max-width: 140px; padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm);"
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLInputElement).value.trim();
|
const value = (e.target as HTMLInputElement).value.trim();
|
||||||
onPatch(row.key, { label: value || null });
|
onPatch(row.key, { label: value || null });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</td>
|
||||||
<div>${row.kind}</div>
|
<td>
|
||||||
<div>${updated}</div>
|
<span class="data-table-badge ${badgeClass}">${row.kind}</span>
|
||||||
<div>${formatSessionTokens(row)}</div>
|
</td>
|
||||||
<div>
|
<td>${updated}</td>
|
||||||
|
<td>${formatSessionTokens(row)}</td>
|
||||||
|
<td>
|
||||||
<select
|
<select
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
|
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, {
|
onPatch(row.key, {
|
||||||
@@ -278,10 +464,11 @@ function renderRow(
|
|||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</td>
|
||||||
<div>
|
<td>
|
||||||
<select
|
<select
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
|
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, { verboseLevel: value || null });
|
onPatch(row.key, { verboseLevel: value || null });
|
||||||
@@ -294,10 +481,11 @@ function renderRow(
|
|||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</td>
|
||||||
<div>
|
<td>
|
||||||
<select
|
<select
|
||||||
?disabled=${disabled}
|
?disabled=${disabled}
|
||||||
|
style="padding: 6px 10px; font-size: 13px; border: 1px solid var(--border); border-radius: var(--radius-sm); min-width: 90px;"
|
||||||
@change=${(e: Event) => {
|
@change=${(e: Event) => {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const value = (e.target as HTMLSelectElement).value;
|
||||||
onPatch(row.key, { reasoningLevel: value || null });
|
onPatch(row.key, { reasoningLevel: value || null });
|
||||||
@@ -310,12 +498,53 @@ function renderRow(
|
|||||||
</option>`,
|
</option>`,
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</td>
|
||||||
<div>
|
<td>
|
||||||
<button class="btn danger" ?disabled=${disabled} @click=${() => onDelete(row.key)}>
|
<div class="data-table-row-actions">
|
||||||
|
<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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
`
|
||||||
|
: nothing
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "./skills-shared.ts";
|
} from "./skills-shared.ts";
|
||||||
|
|
||||||
export type SkillsProps = {
|
export type SkillsProps = {
|
||||||
|
connected: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
report: SkillStatusReport | null;
|
report: SkillStatusReport | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -40,16 +41,22 @@ export function renderSkills(props: SkillsProps) {
|
|||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title">Skills</div>
|
<div class="card-title">Skills</div>
|
||||||
<div class="card-sub">Bundled, managed, and workspace skills.</div>
|
<div class="card-sub">Installed skills and their status.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
<button class="btn" ?disabled=${props.loading || !props.connected} @click=${props.onRefresh}>
|
||||||
${props.loading ? "Loading…" : "Refresh"}
|
${props.loading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters" style="margin-top: 14px;">
|
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 14px;">
|
||||||
<label class="field" style="flex: 1;">
|
<a
|
||||||
<span>Filter</span>
|
class="btn"
|
||||||
|
href="https://clawhub.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Browse skills on ClawHub"
|
||||||
|
>Browse Skills Store</a>
|
||||||
|
<label class="field" style="flex: 1; min-width: 180px;">
|
||||||
<input
|
<input
|
||||||
.value=${props.filter}
|
.value=${props.filter}
|
||||||
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
|
||||||
@@ -68,7 +75,13 @@ export function renderSkills(props: SkillsProps) {
|
|||||||
${
|
${
|
||||||
filtered.length === 0
|
filtered.length === 0
|
||||||
? html`
|
? html`
|
||||||
<div class="muted" style="margin-top: 16px">No skills found.</div>
|
<div class="muted" style="margin-top: 16px">
|
||||||
|
${
|
||||||
|
!props.connected && !props.report
|
||||||
|
? "Not connected to gateway."
|
||||||
|
: "No skills found."
|
||||||
|
}
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<div class="agent-skills-groups" style="margin-top: 16px;">
|
<div class="agent-skills-groups" style="margin-top: 16px;">
|
||||||
|
|||||||
@@ -39,5 +39,23 @@ export default defineConfig(() => {
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
},
|
},
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "control-ui-dev-stubs",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/__openclaw/control-ui-config.json", (_req, res) => {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
basePath: "/",
|
||||||
|
assistantName: "",
|
||||||
|
assistantAvatar: "",
|
||||||
|
assistantAgentId: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user