build: refresh deps and vitest cache lanes

This commit is contained in:
Peter Steinberger 2026-03-27 02:25:58 +00:00
parent b49accc273
commit 10527ff8a3
82 changed files with 6215 additions and 6549 deletions

View File

@ -92,8 +92,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- `pnpm test:max` exposes that same planner profile for a full local run.
- On supported local Node versions, including Node 25, the normal profile can use top-level lane parallelism. `pnpm test:max` still pushes the planner harder when you want a more aggressive local run.
- The base Vitest config marks the wrapper manifests/config files as `forceRerunTriggers` so changed-mode reruns stay correct when scheduler inputs change.
- Vitest's filesystem module cache is now enabled by default for Node-side test reruns.
- Opt out with `OPENCLAW_VITEST_FS_MODULE_CACHE=0` or `OPENCLAW_VITEST_FS_MODULE_CACHE=false` if you suspect stale transform cache behavior.
- The wrapper keeps `OPENCLAW_VITEST_FS_MODULE_CACHE` enabled on supported hosts, but assigns a lane-local `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH` so concurrent Vitest processes do not race on one shared experimental cache directory.
- Set `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/abs/path` if you want one explicit cache location for direct single-run profiling.
- Perf-debug note:
- `pnpm test:perf:imports` enables Vitest import-duration reporting plus import-breakdown output.
- `pnpm test:perf:imports:changed` scopes the same profiling view to files changed since `origin/main`.

View File

@ -40,7 +40,7 @@ For local PR land/gate checks, run:
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
- `OPENCLAW_VITEST_FS_MODULE_CACHE=0 pnpm test:changed`
- `OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-cache pnpm test:changed`
## Model latency bench (local keys)

View File

@ -4,16 +4,16 @@
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.213.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.213.0",
"@opentelemetry/resources": "^2.6.0",
"@opentelemetry/sdk-logs": "^0.213.0",
"@opentelemetry/sdk-metrics": "^2.6.0",
"@opentelemetry/sdk-node": "^0.213.0",
"@opentelemetry/sdk-trace-base": "^2.6.0",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/api-logs": "^0.214.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@opentelemetry/sdk-metrics": "^2.6.1",
"@opentelemetry/sdk-node": "^0.214.0",
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0"
},
"openclaw": {

View File

@ -8,7 +8,7 @@
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
},
"dependencies": {
"@pierre/diffs": "1.1.3",
"@pierre/diffs": "1.1.5",
"@sinclair/typebox": "0.34.48",
"playwright-core": "1.58.2"
},

View File

@ -4,7 +4,7 @@
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.59.0",
"@larksuiteoapi/node-sdk": "^1.60.0",
"@sinclair/typebox": "0.34.48",
"https-proxy-agent": "^8.0.0",
"zod": "^4.3.6"

View File

@ -7,7 +7,7 @@
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"fake-indexeddb": "^6.2.5",
"markdown-it": "14.1.1",
"matrix-js-sdk": "41.2.0-rc.0",
"matrix-js-sdk": "41.2.0",
"music-metadata": "^11.12.3",
"zod": "^4.3.6"
},

View File

@ -6,7 +6,7 @@
"dependencies": {
"@lancedb/lancedb": "^0.27.1",
"@sinclair/typebox": "0.34.48",
"openai": "^6.32.0"
"openai": "^6.33.0"
},
"openclaw": {
"extensions": [

View File

@ -4,8 +4,8 @@
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {
"@microsoft/teams.api": "2.0.5",
"@microsoft/teams.apps": "2.0.5",
"@microsoft/teams.api": "2.0.6",
"@microsoft/teams.apps": "2.0.6",
"express": "^5.2.1"
},
"devDependencies": {

View File

@ -4,8 +4,8 @@
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
"@aws-sdk/client-s3": "3.1014.0",
"@aws-sdk/s3-request-presigner": "3.1014.0",
"@aws-sdk/client-s3": "3.1018.0",
"@aws-sdk/s3-request-presigner": "3.1018.0",
"@tloncorp/tlon-skill": "0.3.0",
"@urbit/aura": "^3.0.0",
"zod": "^4.3.6"

View File

@ -4,7 +4,7 @@
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {
"undici": "7.24.5",
"undici": "7.24.6",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@ -810,18 +810,18 @@
"ui:install": "node scripts/ui.js install"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@agentclientprotocol/sdk": "0.17.0",
"@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock": "^3.1014.0",
"@aws-sdk/client-bedrock": "^3.1018.0",
"@clack/prompts": "^1.1.0",
"@homebridge/ciao": "^1.3.5",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.61.1",
"@mariozechner/pi-ai": "0.61.1",
"@mariozechner/pi-coding-agent": "0.61.1",
"@mariozechner/pi-tui": "0.61.1",
"@modelcontextprotocol/sdk": "1.27.1",
"@mariozechner/pi-agent-core": "0.63.0",
"@mariozechner/pi-ai": "0.63.0",
"@mariozechner/pi-coding-agent": "0.63.0",
"@mariozechner/pi-tui": "0.63.0",
"@modelcontextprotocol/sdk": "1.28.0",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.48",
"ajv": "^8.18.0",
@ -832,9 +832,9 @@
"croner": "^10.0.1",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "21.3.4",
"file-type": "22.0.0",
"gaxios": "7.1.4",
"hono": "4.12.8",
"hono": "4.12.9",
"ipaddr.js": "^2.3.0",
"jiti": "^2.6.1",
"json5": "^2.2.3",
@ -849,9 +849,9 @@
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7",
"tar": "7.5.12",
"tar": "7.5.13",
"tslog": "^4.10.2",
"undici": "^7.24.5",
"undici": "^7.24.6",
"uuid": "^13.0.0",
"ws": "^8.20.0",
"yaml": "^2.8.3",
@ -866,19 +866,19 @@
"@types/node": "^25.5.0",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260322.1",
"@vitest/coverage-v8": "^4.1.0",
"@typescript/native-preview": "7.0.0-dev.20260326.1",
"@vitest/coverage-v8": "^4.1.2",
"jscpd": "4.0.8",
"jsdom": "^29.0.1",
"lit": "^3.3.2",
"oxfmt": "0.41.0",
"oxlint": "^1.56.0",
"oxlint-tsgolint": "^0.17.1",
"oxfmt": "0.42.0",
"oxlint": "^1.57.0",
"oxlint-tsgolint": "^0.17.4",
"signal-utils": "0.21.1",
"tsdown": "0.21.4",
"tsdown": "0.21.5",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"peerDependencies": {
"@napi-rs/canvas": "^0.1.89",
@ -899,18 +899,18 @@
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
"hono": "4.12.8",
"hono": "4.12.9",
"@hono/node-server": "1.19.10",
"fast-xml-parser": "5.5.7",
"request": "npm:@cypress/request@3.0.10",
"request-promise": "npm:@cypress/request-promise@5.0.0",
"file-type": "21.3.4",
"file-type": "22.0.0",
"form-data": "2.5.4",
"minimatch": "10.2.4",
"qs": "6.14.2",
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"@sinclair/typebox": "0.34.48",
"tar": "7.5.12",
"tar": "7.5.13",
"tough-cookie": "4.1.3",
"yauzl": "3.2.1"
},

File diff suppressed because it is too large Load Diff

View File

@ -107,6 +107,53 @@ const getShardLabel = (args) => {
return typeof args[shardIndex + 1] === "string" ? args[shardIndex + 1] : "";
};
const normalizeEnvFlag = (value) => value?.trim().toLowerCase();
const isEnvFlagEnabled = (value) => {
const normalized = normalizeEnvFlag(value);
return normalized === "1" || normalized === "true";
};
const isEnvFlagDisabled = (value) => {
const normalized = normalizeEnvFlag(value);
return normalized === "0" || normalized === "false";
};
const isWindowsEnv = (env, platform = process.platform) => {
if (platform === "win32") {
return true;
}
return normalizeEnvFlag(env.RUNNER_OS) === "windows";
};
const isFsModuleCacheEnabled = (env, platform = process.platform) => {
if (isWindowsEnv(env, platform)) {
return isEnvFlagEnabled(env.OPENCLAW_VITEST_FS_MODULE_CACHE);
}
return !isEnvFlagDisabled(env.OPENCLAW_VITEST_FS_MODULE_CACHE);
};
export const resolveVitestFsModuleCachePath = ({
cwd = process.cwd(),
env = process.env,
platform = process.platform,
unitId = "",
} = {}) => {
const explicitPath = env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH?.trim();
if (!isFsModuleCacheEnabled(env, platform)) {
return undefined;
}
if (explicitPath) {
return explicitPath;
}
return path.join(
cwd,
"node_modules",
".experimental-vitest-cache",
sanitizeArtifactName(unitId || "default"),
);
};
export function formatPlanOutput(plan) {
return [
`runtime=${plan.runtimeCapabilities.runtimeProfileName} mode=${plan.runtimeCapabilities.mode} intent=${plan.runtimeCapabilities.intentProfile} memoryBand=${plan.runtimeCapabilities.memoryBand} loadBand=${plan.runtimeCapabilities.loadBand} vitestMaxWorkers=${String(plan.executionBudget.vitestMaxWorkers ?? "default")} topLevelParallel=${plan.topLevelParallelEnabled ? String(plan.topLevelParallelLimit) : "off"}`,
@ -458,14 +505,24 @@ export async function executePlan(plan, options = {}) {
);
};
try {
const childEnv = {
...env,
...unit.env,
VITEST_GROUP: unit.id,
NODE_OPTIONS: resolvedNodeOptions,
};
const vitestFsModuleCachePath = resolveVitestFsModuleCachePath({
env: childEnv,
platform: process.platform,
unitId: unit.id,
});
if (vitestFsModuleCachePath) {
childEnv.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH = vitestFsModuleCachePath;
laneLogStream.write(`[test-parallel] fsModuleCachePath=${vitestFsModuleCachePath}\n`);
}
child = spawn(pnpmInvocation.command, spawnArgs, {
stdio: ["inherit", "pipe", "pipe"],
env: {
...env,
...unit.env,
VITEST_GROUP: unit.id,
NODE_OPTIONS: resolvedNodeOptions,
},
env: childEnv,
shell: false,
});
captureTreeSample("spawn");

View File

@ -65,7 +65,7 @@ describe("compaction identifier-preservation instructions", () => {
}
function firstSummaryInstructions() {
return mockGenerateSummary.mock.calls[0]?.[5];
return mockGenerateSummary.mock.calls[0]?.[6];
}
it("injects identifier-preservation guidance even without custom instructions", async () => {
@ -101,7 +101,7 @@ describe("compaction identifier-preservation instructions", () => {
expect(mockGenerateSummary.mock.calls.length).toBeGreaterThan(1);
for (const call of mockGenerateSummary.mock.calls) {
expect(call[5]).toContain("Preserve all opaque identifiers exactly as written");
expect(call[6]).toContain("Preserve all opaque identifiers exactly as written");
}
});
@ -114,7 +114,7 @@ describe("compaction identifier-preservation instructions", () => {
});
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
const instructions = mergedCall?.[5] ?? "";
const instructions = mergedCall?.[6] ?? "";
expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
expect(instructions).toContain("Prioritize customer-visible regressions.");
expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);

View File

@ -56,7 +56,7 @@ describe("compaction retry integration", () => {
} as unknown as NonNullable<ExtensionContext["model"]>;
const invokeGenerateSummary = (signal = new AbortController().signal) =>
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", signal);
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", undefined, signal);
const runSummaryRetry = (options: Parameters<typeof retryAsync>[1]) =>
retryAsync(() => invokeGenerateSummary(), options);

View File

@ -212,6 +212,7 @@ async function summarizeChunks(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
@ -239,6 +240,7 @@ async function summarizeChunks(params: {
params.model,
params.reserveTokens,
params.apiKey,
params.headers,
params.signal,
effectiveInstructions,
summary,
@ -265,6 +267,7 @@ export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
@ -334,6 +337,7 @@ export async function summarizeInStages(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;

View File

@ -614,17 +614,29 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
return { cancel: true };
}
const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) {
log.warn(
"Compaction safeguard: no API key available; cancelling compaction to preserve history.",
`Compaction safeguard: failed to resolve auth; cancelling compaction to preserve history. ${auth.error}`,
);
setCompactionSafeguardCancelReason(
ctx.sessionManager,
`Compaction safeguard could not resolve an API key for ${model.provider}/${model.id}.`,
`Compaction safeguard could not resolve request auth for ${model.provider}/${model.id}: ${auth.error}`,
);
return { cancel: true };
}
if (!auth.apiKey && !auth.headers) {
log.warn(
"Compaction safeguard: no request auth available; cancelling compaction to preserve history.",
);
setCompactionSafeguardCancelReason(
ctx.sessionManager,
`Compaction safeguard could not resolve request auth for ${model.provider}/${model.id}.`,
);
return { cancel: true };
}
const apiKey = auth.apiKey ?? "";
const headers = auth.headers;
try {
const modelContextWindow = resolveContextWindowTokens(model);
@ -688,6 +700,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: pruned.droppedMessagesList,
model,
apiKey,
headers,
signal,
reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)),
maxChunkTokens: droppedMaxChunkTokens,
@ -759,6 +772,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: messagesToSummarize,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,
@ -775,6 +789,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: turnPrefixMessages,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,

View File

@ -58,9 +58,15 @@ function buildEntry(name: string): SkillEntry {
skill: {
name,
description: `${name} test skill`,
source: "openclaw-workspace",
filePath: path.join(skillDir, "SKILL.md"),
baseDir: skillDir,
sourceInfo: {
path: path.join(skillDir, "SKILL.md"),
source: "openclaw-workspace",
scope: "project",
origin: "top-level",
baseDir: skillDir,
},
disableModelInvocation: false,
},
frontmatter: {},

View File

@ -444,9 +444,9 @@ export async function installSkill(params: SkillInstallRequest): Promise<SkillIn
// Warn when install is triggered from a non-bundled source.
// Workspace/project/personal agent skills can contain attacker-controlled metadata.
const trustedInstallSources = new Set(["openclaw-bundled", "openclaw-managed", "openclaw-extra"]);
if (!trustedInstallSources.has(entry.skill.source)) {
if (!trustedInstallSources.has(entry.skill.sourceInfo.source)) {
warnings.push(
`WARNING: Skill "${params.skillName}" install triggered from non-bundled source "${entry.skill.source}". Verify the install recipe is trusted.`,
`WARNING: Skill "${params.skillName}" install triggered from non-bundled source "${entry.skill.sourceInfo.source}". Verify the install recipe is trusted.`,
);
}
if (!spec) {

View File

@ -15,9 +15,15 @@ describe("buildWorkspaceSkillStatus", () => {
skill: {
name: "os-scoped",
description: "test",
source: "test",
filePath: "/tmp/os-scoped",
baseDir: "/tmp",
sourceInfo: {
path: "/tmp/os-scoped",
source: "test",
scope: "project",
origin: "top-level",
baseDir: "/tmp",
},
disableModelInvocation: false,
},
frontmatter: {},

View File

@ -189,7 +189,7 @@ function buildSkillStatus(
const bundled =
bundledNames && bundledNames.size > 0
? bundledNames.has(entry.skill.name)
: entry.skill.source === "openclaw-bundled";
: entry.skill.sourceInfo.source === "openclaw-bundled";
const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } =
evaluateEntryRequirementsForCurrentPlatform({
@ -205,7 +205,7 @@ function buildSkillStatus(
return {
name: entry.skill.name,
description: entry.skill.description,
source: entry.skill.source,
source: entry.skill.sourceInfo.source,
bundled,
filePath: entry.skill.filePath,
baseDir: entry.skill.baseDir,

View File

@ -22,9 +22,15 @@ function makeEntry(params: {
skill: {
name: params.name,
description: `desc:${params.name}`,
source: params.source ?? "openclaw-workspace",
filePath: `/tmp/${params.name}/SKILL.md`,
baseDir: `/tmp/${params.name}`,
sourceInfo: {
path: `/tmp/${params.name}/SKILL.md`,
source: params.source ?? "openclaw-workspace",
scope: "project",
origin: "top-level",
baseDir: `/tmp/${params.name}`,
},
disableModelInvocation: false,
},
frontmatter: {},

View File

@ -17,7 +17,13 @@ describe("resolveSkillsPromptForRun", () => {
description: "Demo",
filePath: "/app/skills/demo-skill/SKILL.md",
baseDir: "/app/skills/demo-skill",
source: "openclaw-bundled",
sourceInfo: {
path: "/app/skills/demo-skill/SKILL.md",
source: "openclaw-bundled",
scope: "project",
origin: "top-level",
baseDir: "/app/skills/demo-skill",
},
disableModelInvocation: false,
},
frontmatter: {},

View File

@ -14,7 +14,13 @@ function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/S
description: desc,
filePath,
baseDir: `/skills/${name}`,
source: "workspace",
sourceInfo: {
path: filePath,
source: "workspace",
scope: "project",
origin: "top-level",
baseDir: `/skills/${name}`,
},
disableModelInvocation: false,
};
}

View File

@ -50,7 +50,7 @@ function normalizeAllowlist(input: unknown): string[] | undefined {
const BUNDLED_SOURCES = new Set(["openclaw-bundled"]);
function isBundledSkill(entry: SkillEntry): boolean {
return BUNDLED_SOURCES.has(entry.skill.source);
return BUNDLED_SOURCES.has(entry.skill.sourceInfo.source);
}
export function resolveBundledAllowlist(config?: OpenClawConfig): string[] | undefined {

View File

@ -133,7 +133,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
Status: formatSkillStatus(skill),
Skill: formatSkillName(skill),
Description: theme.muted(skill.description),
Source: skill.source ?? "",
Source: skill.source,
Missing: missing ? theme.warn(missing) : "",
};
});

View File

@ -36,9 +36,16 @@ describe("skills-cli (e2e)", () => {
skill: {
name: "peekaboo",
description: "Capture UI screenshots",
source: "openclaw-bundled",
filePath: path.join(baseDir, "SKILL.md"),
baseDir,
sourceInfo: {
path: path.join(baseDir, "SKILL.md"),
source: "openclaw-bundled",
scope: "project",
origin: "top-level",
baseDir,
},
disableModelInvocation: false,
} as SkillEntry["skill"],
frontmatter: {},
metadata: { emoji: "📸" },

View File

@ -1261,7 +1261,7 @@ export async function collectInstalledSkillsCodeSafetyFindings(params: {
for (const workspaceDir of workspaceDirs) {
const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg });
for (const entry of entries) {
if (entry.skill.source === "openclaw-bundled") {
if (entry.skill.sourceInfo.source === "openclaw-bundled") {
continue;
}

View File

@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
createExecutionArtifacts,
resolvePnpmCommandInvocation,
resolveVitestFsModuleCachePath,
} from "../../scripts/test-planner/executor.mjs";
import {
buildCIExecutionManifest,
@ -375,3 +376,42 @@ describe("resolvePnpmCommandInvocation", () => {
});
});
});
describe("resolveVitestFsModuleCachePath", () => {
it("uses a lane-local cache path by default on non-Windows hosts", () => {
expect(
resolveVitestFsModuleCachePath({
cwd: "/repo",
env: {},
platform: "linux",
unitId: "unit-fast-1",
}),
).toBe("/repo/node_modules/.experimental-vitest-cache/unit-fast-1");
});
it("respects an explicit cache path override", () => {
expect(
resolveVitestFsModuleCachePath({
cwd: "/repo",
env: {
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: "/tmp/custom-vitest-cache",
},
platform: "linux",
unitId: "unit-fast-1",
}),
).toBe("/tmp/custom-vitest-cache");
});
it("does not force a cache path when the cache is disabled", () => {
expect(
resolveVitestFsModuleCachePath({
cwd: "/repo",
env: {
OPENCLAW_VITEST_FS_MODULE_CACHE: "0",
},
platform: "linux",
unitId: "unit-fast-1",
}),
).toBeUndefined();
});
});

View File

@ -25,6 +25,22 @@ describe("loadVitestExperimentalConfig", () => {
});
});
it("passes through the filesystem module cache path when provided", () => {
expect(
loadVitestExperimentalConfig(
{
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH: "/tmp/openclaw-vitest-cache",
},
"linux",
),
).toEqual({
experimental: {
fsModuleCache: true,
fsModuleCachePath: "/tmp/openclaw-vitest-cache",
},
});
});
it("disables the filesystem module cache by default on Windows", () => {
expect(loadVitestExperimentalConfig({}, "win32")).toEqual({});
});

View File

@ -16,10 +16,10 @@
"marked": "^17.0.5"
},
"devDependencies": {
"@vitest/browser-playwright": "4.1.0",
"@vitest/browser-playwright": "4.1.2",
"jsdom": "^29.0.1",
"playwright": "^1.58.2",
"vite": "8.0.1",
"vitest": "4.1.0"
"vite": "8.0.3",
"vitest": "4.1.2"
}
}

View File

@ -109,9 +109,8 @@ function renderCronFilterIcon(hiddenCount: number) {
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
${
hiddenCount > 0
? html`<span
${hiddenCount > 0
? html`<span
style="
position: absolute;
top: -5px;
@ -124,10 +123,9 @@ function renderCronFilterIcon(hiddenCount: number) {
padding: 1px 3px;
pointer-events: none;
"
>${hiddenCount}</span
>${hiddenCount}</span
>`
: ""
}
: ""}
</span>
`;
}
@ -158,9 +156,7 @@ export function renderChatSessionSelect(state: AppViewState) {
group.options,
(entry) => entry.key,
(entry) =>
html`<option value=${entry.key} title=${entry.title}>
${entry.label}
</option>`,
html`<option value=${entry.key} title=${entry.title}>${entry.label}</option>`,
)}
</optgroup>`,
)}
@ -315,13 +311,11 @@ export function renderChatControls(state: AppViewState) {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${
hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")
}
title=${hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>
@ -398,14 +392,28 @@ export function renderChatMobileToggle(state: AppViewState) {
title="Chat settings"
aria-label="Chat settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
</button>
<div class="chat-controls-dropdown" @click=${(e: Event) => {
e.stopPropagation();
}}>
<div
class="chat-controls-dropdown"
@click=${(e: Event) => {
e.stopPropagation();
}}
>
<div class="chat-controls">
<label class="field chat-controls__session">
<select
@ -420,9 +428,7 @@ export function renderChatMobileToggle(state: AppViewState) {
<optgroup label=${group.label}>
${group.options.map(
(opt) => html`
<option value=${opt.key} title=${opt.title}>
${opt.label}
</option>
<option value=${opt.key} title=${opt.title}>${opt.label}</option>
`,
)}
</optgroup>
@ -935,7 +941,9 @@ export function renderTopbarThemeModeToggle(state: AppViewState) {
(opt) => html`
<button
type="button"
class="topbar-theme-mode__btn ${opt.id === state.themeMode ? "topbar-theme-mode__btn--active" : ""}"
class="topbar-theme-mode__btn ${opt.id === state.themeMode
? "topbar-theme-mode__btn--active"
: ""}"
title=${opt.label}
aria-label="Color mode: ${opt.label}"
aria-pressed=${opt.id === state.themeMode}
@ -1019,19 +1027,22 @@ export function renderThemeToggle(state: AppViewState) {
aria-haspopup="menu"
aria-expanded="false"
@click=${toggleOpen}
>${currentThemeIcon(state.theme)}</button>
>
${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>`,
(opt) => html` <button
type="button"
class="theme-orb__option ${opt.id === state.theme ? "theme-orb__option--active" : ""}"
title=${opt.label}
role="menuitemradio"
aria-checked=${opt.id === state.theme}
aria-label=${opt.label}
@click=${(e: Event) => pick(opt, e)}
>
${opt.icon}
</button>`,
)}
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -177,11 +177,9 @@ export function renderMessageGroup(
<span class="chat-group-timestamp">${timestamp}</span>
${renderMessageMeta(meta)}
${normalizedRole === "assistant" && isTtsSupported() ? renderTtsButton(group) : nothing}
${
opts.onDelete
? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right")
: nothing
}
${opts.onDelete
? renderDeleteButton(opts.onDelete, normalizedRole === "user" ? "left" : "right")
: nothing}
</div>
</div>
</div>
@ -387,7 +385,9 @@ function renderDeleteButton(onDelete: () => void, side: DeleteConfirmSide) {
};
requestAnimationFrame(() => document.addEventListener("click", closeOnOutside, true));
}}
>${icons.trash ?? icons.x}</button>
>
${icons.trash ?? icons.x}
</button>
</span>
`;
}
@ -565,7 +565,9 @@ function renderCollapsedToolCards(
<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__count"
>${totalTools} tool${totalTools === 1 ? "" : "s"}</span
>
<span class="chat-tools-summary__names">${summaryLabel}</span>
</summary>
<div class="chat-tools-collapse__body">
@ -693,80 +695,70 @@ function renderGroupedMessage(
return html`
<div class="${bubbleClasses}">
${
hasActions
? html`<div class="chat-bubble-actions">
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
</div>`
: nothing
}
${
isToolMessage
? html`
${hasActions
? html`<div class="chat-bubble-actions">
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
</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
}
${toolSummaryLabel
? html`<span class="chat-tool-msg-summary__names">${toolSummaryLabel}</span>`
: toolPreview
? html`<span class="chat-tool-msg-summary__preview">${toolPreview}</span>`
: nothing}
</summary>
<div class="chat-tool-msg-body">
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing}
${jsonResult
? html`<details class="chat-json-collapse">
<summary class="chat-json-summary">
<span class="chat-json-badge">JSON</span>
<span class="chat-json-label">${jsonSummaryLabel(jsonResult.parsed)}</span>
</summary>
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
</details>`
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
</div>
</details>
`
: html`
: 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
}
${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>
`;
}

View File

@ -81,43 +81,35 @@ export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content:
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${
canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
handleClick?.();
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
: nothing
}
e.preventDefault();
handleClick?.();
}
: nothing}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${
canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
: nothing
}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
${canClick
? html`<span class="chat-tool-card__action"
>${hasText ? "View" : ""} ${icons.check}</span
>`
: nothing}
${isEmpty && !canClick
? html`<span class="chat-tool-card__status">${icons.check}</span>`
: nothing}
</div>
${detail ? html`<div class="chat-tool-card__detail">${detail}</div>` : nothing}
${
isEmpty
? html`
<div class="chat-tool-card__status-text muted">Completed</div>
`
: nothing
}
${
showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing
}
${isEmpty ? html` <div class="chat-tool-card__status-text muted">Completed</div> ` : nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline ? html`<div class="chat-tool-card__inline mono">${card.text}</div>` : nothing}
</div>
`;

View File

@ -18,7 +18,10 @@ export class DashboardHeader extends LitElement {
<div class="dashboard-header__breadcrumb">
<span
class="dashboard-header__breadcrumb-link"
@click=${() => this.dispatchEvent(new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }))}
@click=${() =>
this.dispatchEvent(
new CustomEvent("navigate", { detail: "overview", bubbles: true, composed: true }),
)}
>
OpenClaw
</span>

View File

@ -121,9 +121,7 @@ export const icons = {
<path d="m6 6 12 12" />
</svg>
`,
check: html`
<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
`,
check: html` <svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg> `,
arrowDown: html`
<svg viewBox="0 0 24 24">
<path d="M12 5v14" />
@ -144,8 +142,12 @@ export const icons = {
`,
brain: html`
<svg viewBox="0 0 24 24">
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" />
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" />
<path
d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"
/>
<path
d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"
/>
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" />
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375" />
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" />
@ -236,9 +238,7 @@ export const icons = {
<path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" />
</svg>
`,
circle: html`
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /></svg>
`,
circle: html` <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /></svg> `,
puzzle: html`
<svg viewBox="0 0 24 24">
<path
@ -286,9 +286,7 @@ export const icons = {
<path d="M22 2 11 13" />
</svg>
`,
stop: html`
<svg viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1" /></svg>
`,
stop: html` <svg viewBox="0 0 24 24"><rect width="14" height="14" x="5" y="5" rx="1" /></svg> `,
pin: html`
<svg viewBox="0 0 24 24">
<line x1="12" x2="12" y1="17" y2="22" />

View File

@ -100,7 +100,9 @@ export function renderAgentOverview(params: {
class="workspace-link mono"
@click=${() => onSelectPanel("files")}
title="Open Files tab"
>${workspace}</button>
>
${workspace}
</button>
</div>
</div>
<div class="agent-kv">
@ -113,13 +115,13 @@ export function renderAgentOverview(params: {
</div>
</div>
${
configDirty
? html`
<div class="callout warn" style="margin-top: 16px">You have unsaved config changes.</div>
`
: nothing
}
${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>
@ -132,29 +134,28 @@ export function renderAgentOverview(params: {
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? html`
<option value="">Not set</option>
`
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`
}
${isDefault
? html` <option value="">Not set</option> `
: html`
<option value="">
${defaultPrimary ? `Inherit default (${defaultPrimary})` : "Inherit default"}
</option>
`}
${buildModelOptions(configForm, effectivePrimary ?? undefined, params.modelCatalog)}
</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();
}
}}>
<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">
@ -164,7 +165,9 @@ export function renderAgentOverview(params: {
class="chip-remove"
?disabled=${disabled}
@click=${() => removeChip(i)}
>&times;</button>
>
&times;
</button>
</span>
`,
)}
@ -185,7 +188,12 @@ export function renderAgentOverview(params: {
</div>
</div>
<div class="agent-model-actions">
<button type="button" class="btn btn--sm" ?disabled=${configLoading} @click=${onConfigReload}>
<button
type="button"
class="btn btn--sm"
?disabled=${configLoading}
@click=${onConfigReload}
>
Reload Config
</button>
<button

View File

@ -40,7 +40,9 @@ function renderAgentContextCard(
class="workspace-link mono"
@click=${() => onSelectPanel("files")}
title="Open Files tab"
>${context.workspace}</button>
>
${context.workspace}
</button>
</div>
</div>
<div class="agent-kv">
@ -161,7 +163,11 @@ export function renderAgentChannels(params: {
: "never";
return html`
<section class="grid grid-cols-2">
${renderAgentContextCard(params.context, "Workspace, identity, and model configuration.", params.onSelectPanel)}
${renderAgentContextCard(
params.context,
"Workspace, identity, and model configuration.",
params.onSelectPanel,
)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
@ -172,81 +178,67 @@ export function renderAgentChannels(params: {
${params.loading ? "Refreshing…" : "Refresh"}
</button>
</div>
<div class="muted" style="margin-top: 8px;">
Last refresh: ${lastSuccessLabel}
</div>
${
params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing
}
${
!params.snapshot
? html`
<div class="callout info" style="margin-top: 12px">Load channels to see live status.</div>
`
: nothing
}
${
entries.length === 0
? html`
<div class="muted" style="margin-top: 16px">No channels found.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${entries.map((entry) => {
const summary = summarizeChannelAccounts(entry.accounts);
const status = summary.total
? `${summary.connected}/${summary.total} connected`
: "no accounts";
const configLabel = summary.configured
? `${summary.configured} configured`
: "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
const extras = resolveChannelExtrasFromConfig({
configForm: params.configForm,
channelId: entry.id,
fields: CHANNEL_EXTRA_FIELDS,
});
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.label}</div>
<div class="list-sub mono">${entry.id}</div>
</div>
<div class="list-meta">
<div>${status}</div>
<div>${configLabel}</div>
<div>${enabled}</div>
${
summary.configured === 0
? html`
<div>
<a
href="https://docs.openclaw.ai/channels"
target="_blank"
rel="noopener"
style="color: var(--accent); font-size: 12px"
>Setup guide</a
>
</div>
`
: nothing
}
${
extras.length > 0
? extras.map(
(extra) => html`<div>${extra.label}: ${extra.value}</div>`,
)
: nothing
}
</div>
<div class="muted" style="margin-top: 8px;">Last refresh: ${lastSuccessLabel}</div>
${params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing}
${!params.snapshot
? html`
<div class="callout info" style="margin-top: 12px">
Load channels to see live status.
</div>
`
: nothing}
${entries.length === 0
? html` <div class="muted" style="margin-top: 16px">No channels found.</div> `
: html`
<div class="list" style="margin-top: 16px;">
${entries.map((entry) => {
const summary = summarizeChannelAccounts(entry.accounts);
const status = summary.total
? `${summary.connected}/${summary.total} connected`
: "no accounts";
const configLabel = summary.configured
? `${summary.configured} configured`
: "not configured";
const enabled = summary.total ? `${summary.enabled} enabled` : "disabled";
const extras = resolveChannelExtrasFromConfig({
configForm: params.configForm,
channelId: entry.id,
fields: CHANNEL_EXTRA_FIELDS,
});
return html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${entry.label}</div>
<div class="list-sub mono">${entry.id}</div>
</div>
`;
})}
</div>
`
}
<div class="list-meta">
<div>${status}</div>
<div>${configLabel}</div>
<div>${enabled}</div>
${summary.configured === 0
? html`
<div>
<a
href="https://docs.openclaw.ai/channels"
target="_blank"
rel="noopener"
style="color: var(--accent); font-size: 12px"
>Setup guide</a
>
</div>
`
: nothing}
${extras.length > 0
? extras.map((extra) => html`<div>${extra.label}: ${extra.value}</div>`)
: nothing}
</div>
</div>
`;
})}
</div>
`}
</section>
</section>
`;
@ -266,7 +258,11 @@ export function renderAgentCron(params: {
const jobs = params.jobs.filter((job) => job.agentId === params.agentId);
return html`
<section class="grid grid-cols-2">
${renderAgentContextCard(params.context, "Workspace and scheduling targets.", params.onSelectPanel)}
${renderAgentContextCard(
params.context,
"Workspace and scheduling targets.",
params.onSelectPanel,
)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
@ -293,57 +289,51 @@ export function renderAgentCron(params: {
<div class="stat-value">${formatNextRun(params.status?.nextWakeAtMs ?? null)}</div>
</div>
</div>
${
params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing
}
${params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing}
</section>
</section>
<section class="card">
<div class="card-title">Agent Cron Jobs</div>
<div class="card-sub">Scheduled jobs targeting this agent.</div>
${
jobs.length === 0
? html`
<div class="muted" style="margin-top: 16px">No jobs assigned.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${jobs.map(
(job) => html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${job.name}</div>
${
job.description
? html`<div class="list-sub">${job.description}</div>`
: nothing
}
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${formatCronSchedule(job)}</span>
<span class="chip ${job.enabled ? "chip-ok" : "chip-warn"}">
${job.enabled ? "enabled" : "disabled"}
</span>
<span class="chip">${job.sessionTarget}</span>
</div>
</div>
<div class="list-meta">
<div class="mono">${formatCronState(job)}</div>
<div class="muted">${formatCronPayload(job)}</div>
<button
class="btn btn--sm"
style="margin-top: 6px;"
?disabled=${!job.enabled}
@click=${() => params.onRunNow(job.id)}
>Run Now</button>
${jobs.length === 0
? html` <div class="muted" style="margin-top: 16px">No jobs assigned.</div> `
: html`
<div class="list" style="margin-top: 16px;">
${jobs.map(
(job) => html`
<div class="list-item">
<div class="list-main">
<div class="list-title">${job.name}</div>
${job.description
? html`<div class="list-sub">${job.description}</div>`
: nothing}
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${formatCronSchedule(job)}</span>
<span class="chip ${job.enabled ? "chip-ok" : "chip-warn"}">
${job.enabled ? "enabled" : "disabled"}
</span>
<span class="chip">${job.sessionTarget}</span>
</div>
</div>
`,
)}
</div>
`
}
<div class="list-meta">
<div class="mono">${formatCronState(job)}</div>
<div class="muted">${formatCronPayload(job)}</div>
<button
class="btn btn--sm"
style="margin-top: 6px;"
?disabled=${!job.enabled}
@click=${() => params.onRunNow(job.id)}
>
Run Now
</button>
</div>
</div>
`,
)}
</div>
`}
</section>
`;
}
@ -386,166 +376,168 @@ export function renderAgentFiles(params: {
${params.agentFilesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${
list
? html`<div class="muted mono" style="margin-top: 8px;">Workspace: <span>${list.workspace}</span></div>`
: nothing
}
${
params.agentFilesError
? html`<div class="callout danger" style="margin-top: 12px;">${params.agentFilesError}</div>`
: nothing
}
${
!list
? html`
<div class="callout info" style="margin-top: 12px">
Load the agent workspace files to edit core instructions.
${list
? html`<div class="muted mono" style="margin-top: 8px;">
Workspace: <span>${list.workspace}</span>
</div>`
: nothing}
${params.agentFilesError
? html`<div class="callout danger" style="margin-top: 12px;">
${params.agentFilesError}
</div>`
: nothing}
${!list
? html`
<div class="callout info" style="margin-top: 12px">
Load the agent workspace files to edit core instructions.
</div>
`
: files.length === 0
? html` <div class="muted" style="margin-top: 16px">No files found.</div> `
: html`
<div class="agent-tabs" style="margin-top: 14px;">
${files.map((file) => {
const isActive = active === file.name;
const label = file.name.replace(/\.md$/i, "");
return html`
<button
class="agent-tab ${isActive ? "active" : ""} ${file.missing
? "agent-tab--missing"
: ""}"
@click=${() => params.onSelectFile(file.name)}
>
${label}${file.missing
? html` <span class="agent-tab-badge">missing</span> `
: nothing}
</button>
`;
})}
</div>
`
: files.length === 0
? html`
<div class="muted" style="margin-top: 16px">No files found.</div>
`
: html`
<div class="agent-tabs" style="margin-top: 14px;">
${files.map((file) => {
const isActive = active === file.name;
const label = file.name.replace(/\.md$/i, "");
return html`
<button
class="agent-tab ${isActive ? "active" : ""} ${file.missing ? "agent-tab--missing" : ""}"
@click=${() => params.onSelectFile(file.name)}
>${label}${
file.missing
? html`
<span class="agent-tab-badge">missing</span>
`
: nothing
}</button>
`;
})}
</div>
${
!activeEntry
? html`
<div class="muted" style="margin-top: 16px">Select a file to edit.</div>
`
: html`
<div class="agent-file-header" style="margin-top: 14px;">
<div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
${!activeEntry
? html` <div class="muted" style="margin-top: 16px">Select a file to edit.</div> `
: html`
<div class="agent-file-header" style="margin-top: 14px;">
<div>
<div class="agent-file-sub mono">${activeEntry.path}</div>
</div>
<div class="agent-file-actions">
<button
class="btn btn--sm"
title="Preview rendered markdown"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn.closest(".card")?.querySelector("dialog");
if (dialog) {
dialog.showModal();
}
}}
>
${icons.eye} Preview
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
>
Reset
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
>
${params.agentFileSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
${activeEntry.missing
? html`
<div class="callout info" style="margin-top: 10px">
This file is missing. Saving will create it in the agent workspace.
</div>
<div class="agent-file-actions">
`
: nothing}
<label class="field agent-file-field" style="margin-top: 12px;">
<span>Content</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${(e: Event) => {
const dialog = e.currentTarget as HTMLElement;
dialog
.querySelector(".md-preview-dialog__panel")
?.classList.remove("fullscreen");
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
<div class="md-preview-dialog__actions">
<button
class="btn btn--sm"
title="Preview rendered markdown"
class="btn btn--sm md-preview-expand-btn"
title="Toggle fullscreen"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const dialog = btn.closest(".card")?.querySelector("dialog");
if (dialog) {
dialog.showModal();
const panel = btn.closest(".md-preview-dialog__panel");
if (!panel) {
return;
}
const isFullscreen = panel.classList.toggle("fullscreen");
btn.classList.toggle("is-fullscreen", isFullscreen);
}}
>
${icons.eye} Preview
<span class="when-normal">${icons.maximize} Expand</span
><span class="when-fullscreen">${icons.minimize} Collapse</span>
</button>
<button
class="btn btn--sm"
?disabled=${!isDirty}
@click=${() => params.onFileReset(activeEntry.name)}
title="Edit file"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea");
textarea?.focus();
}}
>
Reset
${icons.edit} Editor
</button>
<button
class="btn btn--sm primary"
?disabled=${params.agentFileSaving || !isDirty}
@click=${() => params.onFileSave(activeEntry.name)}
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>
${params.agentFileSaving ? "Saving…" : "Save"}
${icons.x} Close
</button>
</div>
</div>
${
activeEntry.missing
? html`
<div class="callout info" style="margin-top: 10px">
This file is missing. Saving will create it in the agent workspace.
</div>
`
: nothing
}
<label class="field agent-file-field" style="margin-top: 12px;">
<span>Content</span>
<textarea
class="agent-file-textarea"
.value=${draft}
@input=${(e: Event) =>
params.onFileDraftChange(
activeEntry.name,
(e.target as HTMLTextAreaElement).value,
)}
></textarea>
</label>
<dialog
class="md-preview-dialog"
@click=${(e: Event) => {
const dialog = e.currentTarget as HTMLDialogElement;
if (e.target === dialog) {
dialog.close();
}
}}
@close=${(e: Event) => {
const dialog = e.currentTarget as HTMLElement;
dialog
.querySelector(".md-preview-dialog__panel")
?.classList.remove("fullscreen");
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title mono">${activeEntry.name}</div>
<div class="md-preview-dialog__actions">
<button
class="btn btn--sm md-preview-expand-btn"
title="Toggle fullscreen"
@click=${(e: Event) => {
const btn = e.currentTarget as HTMLElement;
const panel = btn.closest(".md-preview-dialog__panel");
if (!panel) {
return;
}
const isFullscreen = panel.classList.toggle("fullscreen");
btn.classList.toggle("is-fullscreen", isFullscreen);
}}
><span class="when-normal">${icons.maximize} Expand</span><span class="when-fullscreen">${icons.minimize} Collapse</span></button>
<button
class="btn btn--sm"
title="Edit file"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
const textarea =
document.querySelector<HTMLElement>(".agent-file-textarea");
textarea?.focus();
}}
>${icons.edit} Editor</button>
<button
class="btn btn--sm"
@click=${(e: Event) => {
(e.currentTarget as HTMLElement).closest("dialog")?.close();
}}
>${icons.x} Close</button>
</div>
</div>
<div class="md-preview-dialog__body">
${unsafeHTML(applyPreviewTheme(marked.parse(draft, { gfm: true, breaks: true }) as string, { sanitize: (h: string) => DOMPurify.sanitize(h) }))}
</div>
</div>
</dialog>
`
}
`
}
<div class="md-preview-dialog__body">
${unsafeHTML(
applyPreviewTheme(
marked.parse(draft, { gfm: true, breaks: true }) as string,
{ sanitize: (h: string) => DOMPurify.sanitize(h) },
),
)}
</div>
</div>
</dialog>
`}
`}
</section>
`;
}

View File

@ -183,7 +183,11 @@ export function renderAgentTools(params: {
<button class="btn btn--sm" ?disabled=${!editable} @click=${() => updateAll(false)}>
Disable All
</button>
<button class="btn btn--sm" ?disabled=${params.configLoading} @click=${params.onConfigReload}>
<button
class="btn btn--sm"
?disabled=${params.configLoading}
@click=${params.onConfigReload}
>
Reload Config
</button>
<button
@ -196,49 +200,41 @@ export function renderAgentTools(params: {
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to adjust tool profiles.
</div>
`
: nothing
}
${
hasAgentAllow
? html`
<div class="callout info" style="margin-top: 12px">
This agent is using an explicit allowlist in config. Tool overrides are managed in the Config tab.
</div>
`
: nothing
}
${
hasGlobalAllow
? html`
<div class="callout info" style="margin-top: 12px">
Global tools.allow is set. Agent overrides cannot enable tools that are globally blocked.
</div>
`
: 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
}
${!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to adjust tool profiles.
</div>
`
: nothing}
${hasAgentAllow
? html`
<div class="callout info" style="margin-top: 12px">
This agent is using an explicit allowlist in config. Tool overrides are managed in the
Config tab.
</div>
`
: nothing}
${hasGlobalAllow
? html`
<div class="callout info" style="margin-top: 12px">
Global tools.allow is set. Agent overrides cannot enable tools that are globally
blocked.
</div>
`
: nothing}
${params.toolsCatalogLoading && !params.toolsCatalogResult && !params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">Loading runtime tool catalog</div>
`
: nothing}
${params.toolsCatalogError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load runtime tool catalog. Showing built-in fallback list instead.
</div>
`
: nothing}
<div class="agent-tools-meta" style="margin-top: 16px;">
<div class="agent-kv">
@ -249,16 +245,14 @@ export function renderAgentTools(params: {
<div class="label">Source</div>
<div>${profileSource}</div>
</div>
${
params.configDirty
? html`
<div class="agent-kv">
<div class="label">Status</div>
<div class="mono">unsaved</div>
</div>
`
: nothing
}
${params.configDirty
? html`
<div class="agent-kv">
<div class="label">Status</div>
<div class="mono">unsaved</div>
</div>
`
: nothing}
</div>
<div style="margin-top: 18px;">
@ -267,58 +261,60 @@ export function renderAgentTools(params: {
What this agent can use in the current chat session.
<span class="mono">${params.runtimeSessionKey || "no session"}</span>
</div>
${
!params.runtimeSessionMatchesSelectedAgent
${!params.runtimeSessionMatchesSelectedAgent
? html`
<div class="callout info" style="margin-top: 12px">
Switch chat to this agent to view its live runtime tools.
</div>
`
: params.toolsEffectiveLoading &&
!params.toolsEffectiveResult &&
!params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">
Switch chat to this agent to view its live runtime tools.
</div>
<div class="callout info" style="margin-top: 12px">Loading available tools</div>
`
: params.toolsEffectiveLoading &&
!params.toolsEffectiveResult &&
!params.toolsEffectiveError
: params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">Loading available tools</div>
<div class="callout info" style="margin-top: 12px">
Could not load available tools for this session.
</div>
`
: params.toolsEffectiveError
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
? html`
<div class="callout info" style="margin-top: 12px">
Could not load available tools for this session.
No tools are available for this session right now.
</div>
`
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
? html`
<div class="callout info" style="margin-top: 12px">
No tools are available for this session right now.
</div>
`
: html`
<div class="agent-tools-grid" style="margin-top: 16px;">
${params.toolsEffectiveResult?.groups.map(
(group) => html`
<div class="agent-tools-section">
<div class="agent-tools-header">${group.label}</div>
<div class="agent-tools-list">
${group.tools.map((tool) => {
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;">
<span class="agent-pill">${renderEffectiveToolBadge(tool)}</span>
</div>
: html`
<div class="agent-tools-grid" style="margin-top: 16px;">
${params.toolsEffectiveResult?.groups.map(
(group) => html`
<div class="agent-tools-section">
<div class="agent-tools-header">${group.label}</div>
<div class="agent-tools-list">
${group.tools.map((tool) => {
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
<div
style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;"
>
<span class="agent-pill"
>${renderEffectiveToolBadge(tool)}</span
>
</div>
</div>
`;
})}
</div>
</div>
`;
})}
</div>
`,
)}
</div>
`
}
</div>
`,
)}
</div>
`}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
@ -347,43 +343,42 @@ export function renderAgentTools(params: {
<div class="agent-tools-grid" style="margin-top: 20px;">
${toolSections.map(
(section) =>
html`
<div class="agent-tools-section">
<div class="agent-tools-header">
${section.label}
${
section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;">plugin:${section.pluginId}</span>`
: nothing
}
</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
${renderToolBadges(section, tool)}
</div>
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${allowed}
?disabled=${!editable}
@change=${(e: Event) =>
updateTool(tool.id, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
`;
})}
</div>
(section) => html`
<div class="agent-tools-section">
<div class="agent-tools-header">
${section.label}
${section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;"
>plugin:${section.pluginId}</span
>`
: nothing}
</div>
`,
<div class="agent-tools-list">
${section.tools.map((tool) => {
const { allowed } = resolveAllowed(tool.id);
return html`
<div class="agent-tool-row">
<div>
<div class="agent-tool-title mono">${tool.label}</div>
<div class="agent-tool-sub">${tool.description}</div>
${renderToolBadges(section, tool)}
</div>
<label class="cfg-toggle">
<input
type="checkbox"
.checked=${allowed}
?disabled=${!editable}
@change=${(e: Event) =>
updateTool(tool.id, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</label>
</div>
`;
})}
</div>
</div>
`,
)}
</div>
</section>
@ -435,16 +430,21 @@ export function renderAgentSkills(params: {
<div class="card-title">Skills</div>
<div class="card-sub">
Per-agent skill allowlist and workspace skills.
${
totalCount > 0
? html`<span class="mono">${enabledCount}/${totalCount}</span>`
: nothing
}
${totalCount > 0
? html`<span class="mono">${enabledCount}/${totalCount}</span>`
: nothing}
</div>
</div>
<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)}>
<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)}
>
Enable All
</button>
<button
@ -463,7 +463,11 @@ export function renderAgentSkills(params: {
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
</button>
<button class="btn btn--sm" ?disabled=${params.loading} @click=${params.onRefresh}>
@ -479,40 +483,34 @@ export function renderAgentSkills(params: {
</div>
</div>
${
!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to set per-agent skills.
</div>
`
: nothing
}
${
usingAllowlist
? html`
<div class="callout info" style="margin-top: 12px">This agent uses a custom skill allowlist.</div>
`
: html`
<div class="callout info" style="margin-top: 12px">
All skills are enabled. Disabling any skill will create a per-agent allowlist.
</div>
`
}
${
!reportReady && !params.loading
? html`
<div class="callout info" style="margin-top: 12px">
Load skills for this agent to view workspace-specific entries.
</div>
`
: nothing
}
${
params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing
}
${!params.configForm
? html`
<div class="callout info" style="margin-top: 12px">
Load the gateway config to set per-agent skills.
</div>
`
: nothing}
${usingAllowlist
? html`
<div class="callout info" style="margin-top: 12px">
This agent uses a custom skill allowlist.
</div>
`
: html`
<div class="callout info" style="margin-top: 12px">
All skills are enabled. Disabling any skill will create a per-agent allowlist.
</div>
`}
${!reportReady && !params.loading
? html`
<div class="callout info" style="margin-top: 12px">
Load skills for this agent to view workspace-specific entries.
</div>
`
: nothing}
${params.error
? html`<div class="callout danger" style="margin-top: 12px;">${params.error}</div>`
: nothing}
<div class="filters" style="margin-top: 14px;">
<label class="field" style="flex: 1;">
@ -528,25 +526,21 @@ export function renderAgentSkills(params: {
<div class="muted">${filtered.length} shown</div>
</div>
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) =>
renderAgentSkillGroup(group, {
agentId: params.agentId,
allowSet,
usingAllowlist,
editable,
onToggle: params.onToggle,
}),
)}
</div>
`
}
${filtered.length === 0
? html` <div class="muted" style="margin-top: 16px">No skills found.</div> `
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) =>
renderAgentSkillGroup(group, {
agentId: params.agentId,
allowSet,
usingAllowlist,
editable,
onToggle: params.onToggle,
}),
)}
</div>
`}
</section>
`;
}
@ -602,16 +596,12 @@ function renderAgentSkillRow(
<div class="list-title">${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}</div>
<div class="list-sub">${skill.description}</div>
${renderSkillStatusChips({ skill })}
${
missing.length > 0
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
: nothing
}
${
reasons.length > 0
? html`<div class="muted" style="margin-top: 6px;">Reason: ${reasons.join(", ")}</div>`
: nothing
}
${missing.length > 0
? html`<div class="muted" style="margin-top: 6px;">Missing: ${missing.join(", ")}</div>`
: nothing}
${reasons.length > 0
? html`<div class="muted" style="margin-top: 6px;">Reason: ${reasons.join(", ")}</div>`
: nothing}
</div>
<div class="list-meta">
<label class="cfg-toggle">

View File

@ -154,195 +154,188 @@ export function renderAgents(props: AgentsProps) {
?disabled=${props.loading || agents.length === 0}
@change=${(e: Event) => props.onSelectAgent((e.target as HTMLSelectElement).value)}
>
${
agents.length === 0
? html`
<option value="">No agents</option>
`
: agents.map(
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selectedId}>
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId) ? ` (${agentBadgeText(agent.id, defaultId)})` : ""}
</option>
`,
)
}
${agents.length === 0
? html` <option value="">No agents</option> `
: agents.map(
(agent) => html`
<option value=${agent.id} ?selected=${agent.id === selectedId}>
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId)
? ` (${agentBadgeText(agent.id, defaultId)})`
: ""}
</option>
`,
)}
</select>
</div>
<div class="agents-toolbar-actions">
${
selectedAgent
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
@click=${() => void navigator.clipboard.writeText(selectedAgent.id)}
title="Copy agent ID to clipboard"
>Copy ID</button>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => props.onSetDefault(selectedAgent.id)}
title=${defaultId && selectedAgent.id === defaultId ? "Already the default agent" : "Set as the default agent"}
>${defaultId && selectedAgent.id === defaultId ? "Default" : "Set Default"}</button>
`
: nothing
}
<button class="btn btn--sm agents-refresh-btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${selectedAgent
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
@click=${() => void navigator.clipboard.writeText(selectedAgent.id)}
title="Copy agent ID to clipboard"
>
Copy ID
</button>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${Boolean(defaultId && selectedAgent.id === defaultId)}
@click=${() => props.onSetDefault(selectedAgent.id)}
title=${defaultId && selectedAgent.id === defaultId
? "Already the default agent"
: "Set as the default agent"}
>
${defaultId && selectedAgent.id === defaultId ? "Default" : "Set Default"}
</button>
`
: nothing}
<button
class="btn btn--sm agents-refresh-btn"
?disabled=${props.loading}
@click=${props.onRefresh}
>
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
</div>
${
props.error
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
: nothing
}
${props.error
? html`<div class="callout danger" style="margin-top: 8px;">${props.error}</div>`
: nothing}
</section>
<section class="agents-main">
${
!selectedAgent
? html`
<div class="card">
<div class="card-title">Select an agent</div>
<div class="card-sub">Pick an agent to inspect its workspace and tools.</div>
</div>
`
: html`
${renderAgentTabs(props.activePanel, (panel) => props.onSelectPanel(panel), tabCounts)}
${
props.activePanel === "overview"
? renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
defaultId,
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
${
props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
onFileReset: props.onFileReset,
onFileSave: props.onFileSave,
})
: nothing
}
${
props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
toolsEffectiveLoading: props.toolsEffective.loading,
toolsEffectiveError: props.toolsEffective.error,
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent:
props.runtimeSessionMatchesSelectedAgent,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing
}
${
props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
onClear: props.onAgentSkillsClear,
onDisableAll: props.onAgentSkillsDisableAll,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing
}
${
props.activePanel === "channels"
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
${
props.activePanel === "cron"
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
`
}
${!selectedAgent
? html`
<div class="card">
<div class="card-title">Select an agent</div>
<div class="card-sub">Pick an agent to inspect its workspace and tools.</div>
</div>
`
: html`
${renderAgentTabs(
props.activePanel,
(panel) => props.onSelectPanel(panel),
tabCounts,
)}
${props.activePanel === "overview"
? renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
defaultId,
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
})
: nothing}
${props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
onFileReset: props.onFileReset,
onFileSave: props.onFileSave,
})
: nothing}
${props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
toolsEffectiveLoading: props.toolsEffective.loading,
toolsEffectiveError: props.toolsEffective.error,
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent: props.runtimeSessionMatchesSelectedAgent,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing}
${props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
onClear: props.onAgentSkillsClear,
onDisableAll: props.onAgentSkillsDisableAll,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing}
${props.activePanel === "channels"
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
onSelectPanel: props.onSelectPanel,
})
: nothing}
${props.activePanel === "cron"
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
onSelectPanel: props.onSelectPanel,
})
: nothing}
`}
</section>
</div>
`;
@ -370,7 +363,9 @@ function renderAgentTabs(
type="button"
@click=${() => onSelect(tab.id)}
>
${tab.label}${counts[tab.id] != null ? html`<span class="agent-tab-count">${counts[tab.id]}</span>` : nothing}
${tab.label}${counts[tab.id] != null
? html`<span class="agent-tab-count">${counts[tab.id]}</span>`
: nothing}
</button>
`,
)}

View File

@ -86,15 +86,11 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
const analysis = analyzeConfigSchema(props.schema);
const normalized = analysis.schema;
if (!normalized) {
return html`
<div class="callout danger">Schema unavailable. Use Raw.</div>
`;
return html` <div class="callout danger">Schema unavailable. Use Raw.</div> `;
}
const node = resolveSchemaNode(normalized, ["channels", props.channelId]);
if (!node) {
return html`
<div class="callout danger">Channel config schema unavailable.</div>
`;
return html` <div class="callout danger">Channel config schema unavailable.</div> `;
}
const configValue = props.configValue ?? {};
const value = resolveChannelValue(configValue, props.channelId);
@ -120,20 +116,16 @@ export function renderChannelConfigSection(params: { channelId: string; props: C
const disabled = props.configSaving || props.configSchemaLoading;
return html`
<div style="margin-top: 16px;">
${
props.configSchemaLoading
? html`
<div class="muted">Loading config schema</div>
`
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})
}
${props.configSchemaLoading
? html` <div class="muted">Loading config schema…</div> `
: renderChannelConfigForm({
channelId,
configValue: props.configForm,
schema: props.configSchema,
uiHints: props.configUiHints,
disabled,
onPatch: props.onConfigPatch,
})}
<div class="row" style="margin-top: 12px;">
<button
class="btn primary"
@ -142,11 +134,7 @@ export function renderChannelConfigSection(params: { channelId: string; props: C
>
${props.configSaving ? "Saving…" : "Save"}
</button>
<button
class="btn"
?disabled=${disabled}
@click=${() => props.onConfigReload()}
>
<button class="btn" ?disabled=${disabled} @click=${() => props.onConfigReload()}>
Reload
</button>
</div>

View File

@ -36,15 +36,13 @@ export function renderDiscordCard(params: {
lastError: discord?.lastError,
secondaryCallout: discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
Probe ${discord.probe.ok ? "ok" : "failed"} · ${discord.probe.status ?? ""}
${discord.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "discord", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -46,15 +46,13 @@ export function renderGoogleChatCard(params: {
lastError: googleChat?.lastError,
secondaryCallout: googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
Probe ${googleChat.probe.ok ? "ok" : "failed"} · ${googleChat.probe.status ?? ""}
${googleChat.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "googlechat", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -36,15 +36,12 @@ export function renderIMessageCard(params: {
lastError: imessage?.lastError,
secondaryCallout: imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
Probe ${imessage.probe.ok ? "ok" : "failed"} · ${imessage.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "imessage", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -108,8 +108,16 @@ export function renderNostrProfileForm(params: {
}}
?disabled=${state.saving}
></textarea>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
${help
? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
${help}
</div>`
: nothing}
${error
? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">
${error}
</div>`
: nothing}
</div>
`;
}
@ -132,8 +140,16 @@ export function renderNostrProfileForm(params: {
}}
?disabled=${state.saving}
/>
${help ? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">${help}</div>` : nothing}
${error ? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">${error}</div>` : nothing}
${help
? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
${help}
</div>`
: nothing}
${error
? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">
${error}
</div>`
: nothing}
</div>
`;
};
@ -164,82 +180,75 @@ export function renderNostrProfileForm(params: {
};
return html`
<div class="nostr-profile-form" style="padding: 16px; background: var(--bg-secondary); border-radius: var(--radius-md); margin-top: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<div
class="nostr-profile-form"
style="padding: 16px; background: var(--bg-secondary); border-radius: var(--radius-md); margin-top: 12px;"
>
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"
>
<div style="font-weight: 600; font-size: 16px;">Edit Profile</div>
<div style="font-size: 12px; color: var(--text-muted);">Account: ${accountId}</div>
</div>
${
state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing
}
${
state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing
}
${state.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${state.error}</div>`
: nothing}
${state.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing}
${renderPicturePreview()}
${renderField("name", "Username", {
placeholder: "satoshi",
maxLength: 256,
help: "Short username (e.g., satoshi)",
})}
${renderField("displayName", "Display Name", {
placeholder: "Satoshi Nakamoto",
maxLength: 256,
help: "Your full display name",
})}
${renderField("about", "Bio", {
type: "textarea",
placeholder: "Tell people about yourself...",
maxLength: 2000,
help: "A brief bio or description",
})}
${renderField("picture", "Avatar URL", {
type: "url",
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
})}
${
state.showAdvanced
? html`
<div style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
${state.showAdvanced
? html`
<div
style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;"
>
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">
Advanced
</div>
${renderField("banner", "Banner URL", {
type: "url",
placeholder: "https://example.com/banner.jpg",
help: "HTTPS URL to a banner image",
})}
${renderField("website", "Website", {
type: "url",
placeholder: "https://example.com",
help: "Your personal website",
})}
${renderField("nip05", "NIP-05 Identifier", {
placeholder: "you@example.com",
help: "Verifiable identifier (e.g., you@domain.com)",
})}
${renderField("lud16", "Lightning Address", {
placeholder: "you@getalby.com",
help: "Lightning address for tips (LUD-16)",
})}
</div>
`
: nothing
}
: nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
@ -258,31 +267,20 @@ export function renderNostrProfileForm(params: {
${state.importing ? "Importing..." : "Import from Relays"}
</button>
<button
class="btn"
@click=${callbacks.onToggleAdvanced}
>
<button class="btn" @click=${callbacks.onToggleAdvanced}>
${state.showAdvanced ? "Hide Advanced" : "Show Advanced"}
</button>
<button
class="btn"
@click=${callbacks.onCancel}
?disabled=${state.saving}
>
Cancel
</button>
<button class="btn" @click=${callbacks.onCancel} ?disabled=${state.saving}>Cancel</button>
</div>
${
isDirty
? html`
<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px">
You have unsaved changes
</div>
`
: nothing
}
${isDirty
? html`
<div style="font-size: 12px; color: var(--warning-color); margin-top: 8px">
You have unsaved changes
</div>
`
: nothing}
</div>
`;
}

View File

@ -79,15 +79,15 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
<span
>${account.lastInboundAt
? formatRelativeTimestamp(account.lastInboundAt)
: "n/a"}</span
>
</div>
${
account.lastError
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing
}
${account.lastError
? html` <div class="account-card-error">${account.lastError}</div> `
: nothing}
</div>
</div>
`;
@ -121,12 +121,15 @@ export function renderNostrCard(params: {
const hasAnyProfileData = name || displayName || about || picture || nip05;
return html`
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-md);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div
style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-md);"
>
<div
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"
>
<div style="font-weight: 500;">Profile</div>
${
summaryConfigured
? html`
${summaryConfigured
? html`
<button
class="btn btn--sm"
@click=${onEditProfile}
@ -135,16 +138,13 @@ export function renderNostrCard(params: {
Edit Profile
</button>
`
: nothing
}
: nothing}
</div>
${
hasAnyProfileData
? html`
${hasAnyProfileData
? html`
<div class="status-list">
${
picture
? html`
${picture
? html`
<div style="margin-bottom: 8px;">
<img
src=${picture}
@ -156,28 +156,33 @@ export function renderNostrCard(params: {
/>
</div>
`
: nothing
}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${
displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing
}
${
about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing
}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
: nothing}
${name
? html`<div><span class="label">Name</span><span>${name}</span></div>`
: nothing}
${displayName
? html`<div>
<span class="label">Display Name</span><span>${displayName}</span>
</div>`
: nothing}
${about
? html`<div>
<span class="label">About</span
><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;"
>${about}</span
>
</div>`
: nothing}
${nip05
? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>`
: nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`
}
: html`
<div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`}
</div>
`;
};
@ -187,15 +192,13 @@ export function renderNostrCard(params: {
<div class="card-title">Nostr</div>
<div class="card-sub">Decentralized DMs via Nostr relays (NIP-04).</div>
${accountCountLabel}
${
hasMultipleAccounts
? html`
${hasMultipleAccounts
? html`
<div class="account-card-list">
${nostrAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@ -213,21 +216,16 @@ export function renderNostrCard(params: {
</div>
<div>
<span class="label">Last start</span>
<span>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span>
<span
>${summaryLastStartAt ? formatRelativeTimestamp(summaryLastStartAt) : "n/a"}</span
>
</div>
</div>
`
}
${
summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing
}
${renderProfileSection()}
${renderChannelConfigSection({ channelId: "nostr", props })}
`}
${summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing}
${renderProfileSection()} ${renderChannelConfigSection({ channelId: "nostr", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(false)}>Refresh</button>

View File

@ -120,18 +120,11 @@ export function renderSingleAccountChannelCard(params: {
)}
</div>
${
params.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${params.lastError}
</div>`
: nothing
}
${params.secondaryCallout ?? nothing}
${params.extraContent ?? nothing}
${params.configSection}
${params.footer ?? nothing}
${params.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${params.lastError}</div>`
: nothing}
${params.secondaryCallout ?? nothing} ${params.extraContent ?? nothing}
${params.configSection} ${params.footer ?? nothing}
</div>
`;
}

View File

@ -37,15 +37,13 @@ export function renderSignalCard(params: {
lastError: signal?.lastError,
secondaryCallout: signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
Probe ${signal.probe.ok ? "ok" : "failed"} · ${signal.probe.status ?? ""}
${signal.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "signal", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -36,15 +36,13 @@ export function renderSlackCard(params: {
lastError: slack?.lastError,
secondaryCallout: slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
Probe ${slack.probe.ok ? "ok" : "failed"} · ${slack.probe.status ?? ""}
${slack.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "slack", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -26,9 +26,7 @@ export function renderTelegramCard(params: {
return html`
<div class="account-card">
<div class="account-card-header">
<div class="account-card-title">
${botUsername ? `@${botUsername}` : label}
</div>
<div class="account-card-title">${botUsername ? `@${botUsername}` : label}</div>
<div class="account-card-id">${account.accountId}</div>
</div>
<div class="status-list account-card-status">
@ -42,17 +40,15 @@ export function renderTelegramCard(params: {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
<span
>${account.lastInboundAt
? formatRelativeTimestamp(account.lastInboundAt)
: "n/a"}</span
>
</div>
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing
}
${account.lastError
? html` <div class="account-card-error">${account.lastError}</div> `
: nothing}
</div>
</div>
`;
@ -69,29 +65,19 @@ export function renderTelegramCard(params: {
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
${
telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${telegram.lastError}</div>`
: nothing}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} · ${telegram.probe.status ?? ""}
${telegram.probe.error ?? ""}
</div>`
: nothing
}
${
telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
</div>`
: nothing
}
: nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>
</div>
`;
@ -117,15 +103,13 @@ export function renderTelegramCard(params: {
lastError: telegram?.lastError,
secondaryCallout: telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${telegram.probe.ok ? "ok" : "failed"} ·
${telegram.probe.status ?? ""} ${telegram.probe.error ?? ""}
Probe ${telegram.probe.ok ? "ok" : "failed"} · ${telegram.probe.status ?? ""}
${telegram.probe.error ?? ""}
</div>`
: nothing,
configSection: renderChannelConfigSection({ channelId: "telegram", props }),
footer: html`<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Probe</button>
</div>`,
});
}

View File

@ -78,18 +78,17 @@ export function renderChannels(props: ChannelsProps) {
<div class="card-title">Channel health</div>
<div class="card-sub">Channel status snapshots from the gateway.</div>
</div>
<div class="muted">${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}</div>
<div class="muted">
${props.lastSuccessAt ? formatRelativeTimestamp(props.lastSuccessAt) : "n/a"}
</div>
</div>
${
props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing
}
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${props.lastError}</div>`
: nothing}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
</pre
>
</section>
`;
}
@ -199,15 +198,13 @@ function renderGenericChannelCard(
<div class="card-title">${label}</div>
<div class="card-sub">Channel status and configuration.</div>
${accountCountLabel}
${
accounts.length > 0
? html`
${accounts.length > 0
? html`
<div class="account-card-list">
${accounts.map((account) => renderGenericAccount(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@ -222,17 +219,10 @@ function renderGenericChannelCard(
<span>${formatNullableBoolean(displayState.connected)}</span>
</div>
</div>
`
}
${
lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${lastError}
</div>`
: nothing
}
`}
${lastError
? html`<div class="callout danger" style="margin-top: 12px;">${lastError}</div>`
: nothing}
${renderChannelConfigSection({ channelId: key, props })}
</div>
`;
@ -311,17 +301,13 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div>
<div>
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span>
<span
>${account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"}</span
>
</div>
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing
}
${account.lastError
? html` <div class="account-card-error">${account.lastError}</div> `
: nothing}
</div>
</div>
`;

View File

@ -43,21 +43,14 @@ export function renderWhatsAppCard(params: {
],
lastError: whatsapp?.lastError,
extraContent: html`
${
props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing
}
${
props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing
}
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">${props.whatsappMessage}</div>`
: nothing}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
`,
configSection: renderChannelConfigSection({ channelId: "whatsapp", props }),
footer: html`<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
@ -75,11 +68,7 @@ export function renderWhatsAppCard(params: {
>
Relink
</button>
<button
class="btn"
?disabled=${props.whatsappBusy}
@click=${() => props.onWhatsAppWait()}
>
<button class="btn" ?disabled=${props.whatsappBusy} @click=${() => props.onWhatsAppWait()}>
Wait for scan
</button>
<button
@ -89,9 +78,7 @@ export function renderWhatsAppCard(params: {
>
Logout
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Refresh
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>Refresh</button>
</div>`,
});
}

View File

@ -195,7 +195,11 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
}
if (status.active) {
return html`
<div class="compaction-indicator compaction-indicator--active" role="status" aria-live="polite">
<div
class="compaction-indicator compaction-indicator--active"
role="status"
aria-live="polite"
>
${icons.loader} Compacting context...
</div>
`;
@ -204,7 +208,11 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
const elapsed = Date.now() - status.completedAt;
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
return html`
<div class="compaction-indicator compaction-indicator--complete" role="status" aria-live="polite">
<div
class="compaction-indicator compaction-indicator--complete"
role="status"
aria-live="polite"
>
${icons.check} Context compacted
</div>
`;
@ -314,9 +322,25 @@ function renderContextNotice(
const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`;
return html`
<div class="context-notice" role="status" style="--ctx-color:${color};--ctx-bg:${bg}">
<svg class="context-notice__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<svg
class="context-notice__icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<span>${pct}% context used</span>
<span class="context-notice__detail">${formatTokensCompact(used)} / ${formatTokensCompact(limit)}</span>
<span class="context-notice__detail"
>${formatTokensCompact(used)} / ${formatTokensCompact(limit)}</span
>
</div>
`;
}
@ -451,7 +475,9 @@ function renderAttachmentPreview(props: ChatProps): TemplateResult | typeof noth
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
props.onAttachmentsChange?.(next);
}}
>&times;</button>
>
&times;
</button>
</div>
`,
)}
@ -616,18 +642,20 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
return html`
<div class="agent-chat__welcome" style="--agent-color: var(--accent)">
<div class="agent-chat__welcome-glow"></div>
${
avatar
? html`<img src=${avatar} alt=${name} style="width:56px; height:56px; border-radius:50%; object-fit:cover;" />`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo"><img src=${logoUrl} alt="OpenClaw" /></div>`
}
${avatar
? html`<img
src=${avatar}
alt=${name}
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
/>`
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
</div>
<p class="agent-chat__hint">
Type a message below &middot; <kbd>/</kbd> for commands
</p>
<p class="agent-chat__hint">Type a message below &middot; <kbd>/</kbd> for commands</p>
<div class="agent-chat__suggestions">
${WELCOME_SUGGESTIONS.map(
(text) => html`
@ -638,7 +666,9 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
props.onDraftChange(text);
props.onSend();
}}
>${text}</button>
>
${text}
</button>
`,
)}
</div>
@ -663,11 +693,15 @@ function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof not
requestUpdate();
}}
/>
<button class="btn btn--ghost" aria-label="Close search" @click=${() => {
vs.searchOpen = false;
vs.searchQuery = "";
requestUpdate();
}}>
<button
class="btn btn--ghost"
aria-label="Close search"
@click=${() => {
vs.searchOpen = false;
vs.searchQuery = "";
requestUpdate();
}}
>
${icons.x}
</button>
</div>
@ -695,36 +729,46 @@ function renderPinnedSection(
}
return html`
<div class="agent-chat__pinned">
<button class="agent-chat__pinned-toggle" @click=${() => {
vs.pinnedExpanded = !vs.pinnedExpanded;
requestUpdate();
}}>
${icons.bookmark}
${entries.length} pinned
<span class="collapse-chevron ${vs.pinnedExpanded ? "" : "collapse-chevron--collapsed"}">${icons.chevronDown}</span>
<button
class="agent-chat__pinned-toggle"
@click=${() => {
vs.pinnedExpanded = !vs.pinnedExpanded;
requestUpdate();
}}
>
${icons.bookmark} ${entries.length} pinned
<span class="collapse-chevron ${vs.pinnedExpanded ? "" : "collapse-chevron--collapsed"}"
>${icons.chevronDown}</span
>
</button>
${
vs.pinnedExpanded
? html`
${vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${entries.map(
({ index, text, role }) => html`
<div class="agent-chat__pinned-item">
<span class="agent-chat__pinned-role">${role === "user" ? "You" : "Assistant"}</span>
<span class="agent-chat__pinned-text">${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</span>
<button class="btn btn--ghost" @click=${() => {
pinned.unpin(index);
requestUpdate();
}} title="Unpin">
${icons.x}
</button>
</div>
`,
<div class="agent-chat__pinned-item">
<span class="agent-chat__pinned-role"
>${role === "user" ? "You" : "Assistant"}</span
>
<span class="agent-chat__pinned-text"
>${text.slice(0, 100)}${text.length > 100 ? "..." : ""}</span
>
<button
class="btn btn--ghost"
@click=${() => {
pinned.unpin(index);
requestUpdate();
}}
title="Unpin"
>
${icons.x}
</button>
</div>
`,
)}
</div>
`
: nothing
}
: nothing}
</div>
`;
}
@ -742,7 +786,9 @@ function renderSlashMenu(
return html`
<div class="slash-menu" role="listbox" aria-label="Command arguments">
<div class="slash-menu-group">
<div class="slash-menu-group__label">/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}</div>
<div class="slash-menu-group__label">
/${vs.slashMenuCommand.name} ${vs.slashMenuCommand.description}
</div>
${vs.slashMenuArgItems.map(
(arg, i) => html`
<div
@ -755,7 +801,9 @@ function renderSlashMenu(
requestUpdate();
}}
>
${vs.slashMenuCommand?.icon ? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>` : nothing}
${vs.slashMenuCommand?.icon
? html`<span class="slash-menu-icon">${icons[vs.slashMenuCommand.icon]}</span>`
: nothing}
<span class="slash-menu-name">${arg}</span>
<span class="slash-menu-desc">/${vs.slashMenuCommand?.name} ${arg}</span>
</div>
@ -763,10 +811,7 @@ function renderSlashMenu(
)}
</div>
<div class="slash-menu-footer">
<kbd></kbd> navigate
<kbd>Tab</kbd> fill
<kbd>Enter</kbd> run
<kbd>Esc</kbd> close
<kbd></kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> run <kbd>Esc</kbd> close
</div>
</div>
`;
@ -800,7 +845,9 @@ function renderSlashMenu(
${entries.map(
({ cmd, globalIdx }) => html`
<div
class="slash-menu-item ${globalIdx === vs.slashMenuIndex ? "slash-menu-item--active" : ""}"
class="slash-menu-item ${globalIdx === vs.slashMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${globalIdx === vs.slashMenuIndex}
@click=${() => selectSlashCommand(cmd, props, requestUpdate)}
@ -813,15 +860,11 @@ function renderSlashMenu(
<span class="slash-menu-name">/${cmd.name}</span>
${cmd.args ? html`<span class="slash-menu-args">${cmd.args}</span>` : nothing}
<span class="slash-menu-desc">${cmd.description}</span>
${
cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html`
<span class="slash-menu-badge">instant</span>
`
: nothing
}
${cmd.argOptions?.length
? html`<span class="slash-menu-badge">${cmd.argOptions.length} options</span>`
: cmd.executeLocal && !cmd.args
? html` <span class="slash-menu-badge">instant</span> `
: nothing}
</div>
`,
)}
@ -833,10 +876,7 @@ function renderSlashMenu(
<div class="slash-menu" role="listbox" aria-label="Slash commands">
${sections}
<div class="slash-menu-footer">
<kbd></kbd> navigate
<kbd>Tab</kbd> fill
<kbd>Enter</kbd> select
<kbd>Esc</kbd> close
<kbd></kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
</div>
</div>
`;
@ -904,15 +944,20 @@ export function renderChat(props: ChatProps) {
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${
props.loading
${props.loading
? html`
<div class="chat-loading-skeleton" aria-label="Loading chat">
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div class="skeleton skeleton-line skeleton-line--medium" style="margin-bottom: 8px"></div>
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div
class="skeleton skeleton-line skeleton-line--medium"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
@ -927,70 +972,68 @@ export function renderChat(props: ChatProps) {
<div class="chat-line assistant" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--long" style="margin-bottom: 8px"></div>
<div
class="skeleton skeleton-line skeleton-line--long"
style="margin-bottom: 8px"
></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
</div>
</div>
</div>
`
: nothing
}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${
isEmpty && vs.searchOpen
? html`
<div class="agent-chat__empty">No matching messages</div>
`
: nothing
}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
: nothing}
${isEmpty && !vs.searchOpen ? renderWelcomeState(props) : nothing}
${isEmpty && vs.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
: nothing}
${repeat(
chatItems,
(item) => item.key,
(item) => {
if (item.kind === "divider") {
return html`
<div class="chat-divider" role="separator" data-ts=${String(item.timestamp)}>
<span class="chat-divider__line"></span>
<span class="chat-divider__label">${item.label}</span>
<span class="chat-divider__line"></span>
</div>
`;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
)}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
props.basePath,
);
}
if (item.kind === "group") {
if (deleted.has(item.key)) {
return nothing;
}
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
contextWindow:
activeSession?.contextTokens ?? props.sessions?.defaults?.contextTokens ?? null,
onDelete: () => {
deleted.delete(item.key);
requestUpdate();
},
});
}
return nothing;
},
)}
</div>
</div>
`;
@ -1121,10 +1164,8 @@ export function renderChat(props: ChatProps) {
>
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${
props.focusMode
? html`
${props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@ -1135,11 +1176,8 @@ export function renderChat(props: ChatProps) {
${icons.x}
</button>
`
: nothing
}
${renderSearchBar(requestUpdate)}
${renderPinnedSection(props, pinned, requestUpdate)}
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
<div
@ -1149,9 +1187,8 @@ export function renderChat(props: ChatProps) {
${thread}
</div>
${
sidebarOpen
? html`
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
@ -1170,13 +1207,11 @@ export function renderChat(props: ChatProps) {
})}
</div>
`
: nothing
}
: nothing}
</div>
${
props.queue.length
? html`
${props.queue.length
? html`
<div class="chat-queue" role="status" aria-live="polite">
<div class="chat-queue__title">Queued (${props.queue.length})</div>
<div class="chat-queue__list">
@ -1184,10 +1219,8 @@ export function renderChat(props: ChatProps) {
(item) => html`
<div class="chat-queue__item">
<div class="chat-queue__text">
${
item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")
}
${item.text ||
(item.attachments?.length ? `Image (${item.attachments.length})` : "")}
</div>
<button
class="btn chat-queue__remove"
@ -1203,31 +1236,21 @@ export function renderChat(props: ChatProps) {
</div>
</div>
`
: nothing
}
: nothing}
${renderFallbackIndicator(props.fallbackStatus)}
${renderCompactionIndicator(props.compactionStatus)}
${renderContextNotice(activeSession, props.sessions?.defaults?.contextTokens ?? null)}
${
props.showNewMessages
? html`
<button
class="chat-new-messages"
type="button"
@click=${props.onScrollToBottom}
>
${props.showNewMessages
? html`
<button class="chat-new-messages" type="button" @click=${props.onScrollToBottom}>
${icons.arrowDown} New messages
</button>
`
: nothing
}
: nothing}
<!-- Input bar -->
<div class="agent-chat__input">
${renderSlashMenu(requestUpdate, props)}
${renderAttachmentPreview(props)}
${renderSlashMenu(requestUpdate, props)} ${renderAttachmentPreview(props)}
<input
type="file"
@ -1237,7 +1260,9 @@ export function renderChat(props: ChatProps) {
@change=${(e: Event) => handleFileSelect(e, props)}
/>
${vs.sttRecording && vs.sttInterimText ? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>` : nothing}
${vs.sttRecording && vs.sttInterimText
? html`<div class="agent-chat__stt-interim">${vs.sttInterimText}</div>`
: nothing}
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
@ -1265,11 +1290,12 @@ export function renderChat(props: ChatProps) {
${icons.paperclip}
</button>
${
isSttSupported()
? html`
${isSttSupported()
? html`
<button
class="agent-chat__input-btn ${vs.sttRecording ? "agent-chat__input-btn--recording" : ""}"
class="agent-chat__input-btn ${vs.sttRecording
? "agent-chat__input-btn--recording"
: ""}"
@click=${() => {
if (vs.sttRecording) {
stopStt();
@ -1316,40 +1342,46 @@ export function renderChat(props: ChatProps) {
${vs.sttRecording ? icons.micOff : icons.mic}
</button>
`
: nothing
}
: nothing}
${tokens ? html`<span class="agent-chat__token-count">${tokens}</span>` : nothing}
</div>
<div class="agent-chat__toolbar-right">
${nothing /* search hidden for now */}
${
canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
title="New session"
aria-label="New session"
>
${icons.plus}
</button>
`
}
<button class="btn btn--ghost" @click=${() => exportMarkdown(props)} title="Export" aria-label="Export chat" ?disabled=${props.messages.length === 0}>
${canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
title="New session"
aria-label="New session"
>
${icons.plus}
</button>
`}
<button
class="btn btn--ghost"
@click=${() => exportMarkdown(props)}
title="Export"
aria-label="Export chat"
?disabled=${props.messages.length === 0}
>
${icons.download}
</button>
${
canAbort && (isBusy || props.sending)
? html`
<button class="chat-send-btn chat-send-btn--stop" @click=${props.onAbort} title="Stop" aria-label="Stop generating">
${canAbort && (isBusy || props.sending)
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}
title="Stop"
aria-label="Stop generating"
>
${icons.stop}
</button>
`
: html`
: html`
<button
class="chat-send-btn"
@click=${() => {
@ -1364,8 +1396,7 @@ export function renderChat(props: ChatProps) {
>
${icons.send}
</button>
`
}
`}
</div>
</div>
</div>

View File

@ -197,10 +197,13 @@ export function renderCommandPalette(props: CommandPaletteProps) {
const grouped = groupItems(items);
return html`
<div class="cmd-palette-overlay" @click=${() => {
props.onToggle();
restoreFocus();
}}>
<div
class="cmd-palette-overlay"
@click=${() => {
props.onToggle();
restoreFocus();
}}
>
<div
class="cmd-palette"
@click=${(e: Event) => e.stopPropagation()}
@ -217,40 +220,42 @@ export function renderCommandPalette(props: CommandPaletteProps) {
}}
/>
<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>
`;
})}
`,
)
}
${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>

View File

@ -79,7 +79,9 @@ const icons = {
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
></path>
</svg>
`,
edit: html`
@ -151,20 +153,16 @@ function renderSensitiveToggleButton(params: {
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"
}
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)}
@ -385,9 +383,7 @@ function renderTags(tags: string[]): TemplateResult | typeof nothing {
return nothing;
}
return html`
<div class="cfg-tags">
${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}
</div>
<div class="cfg-tags">${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}</div>
`;
}
@ -456,26 +452,25 @@ export function renderNode(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<div class="cfg-segmented">
${literals.map(
(lit) => html`
<button
type="button"
class="cfg-segmented__btn ${
// oxlint-disable typescript/no-base-to-string
lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""
}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${
// oxlint-disable typescript/no-base-to-string
String(lit)
}
</button>
`,
<button
type="button"
class="cfg-segmented__btn ${
// oxlint-disable typescript/no-base-to-string
lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""
}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${
// oxlint-disable typescript/no-base-to-string
String(lit)
}
</button>
`,
)}
</div>
</div>
@ -536,20 +531,22 @@ export function renderNode(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<div class="cfg-segmented">
${options.map(
(opt) => html`
<button
type="button"
class="cfg-segmented__btn ${opt === resolvedValue || String(opt) === String(resolvedValue) ? "active" : ""}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
</button>
`,
<button
type="button"
class="cfg-segmented__btn ${opt === resolvedValue ||
String(opt) === String(resolvedValue)
? "active"
: ""}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${String(opt)}
</button>
`,
)}
</div>
</div>
@ -652,8 +649,7 @@ function renderTextInput(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<div class="cfg-input-wrap">
<input
type=${effectiveInputType}
@ -697,19 +693,19 @@ function renderTextInput(params: {
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${
schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled || sensitiveState.isRedacted}
@click=${() => onPatch(path, schema.default)}
></button>
`
: nothing
}
${schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
title="Reset to default"
?disabled=${disabled || sensitiveState.isRedacted}
@click=${() => onPatch(path, schema.default)}
>
</button>
`
: nothing}
</div>
</div>
`;
@ -734,15 +730,16 @@ function renderNumberInput(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<div class="cfg-number">
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue - 1)}
></button>
>
</button>
<input
type="number"
class="cfg-number__input"
@ -759,7 +756,9 @@ function renderNumberInput(params: {
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue + 1)}
>+</button>
>
+
</button>
</div>
</div>
`;
@ -788,8 +787,7 @@ function renderSelect(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<select
class="cfg-select"
?disabled=${disabled}
@ -800,11 +798,7 @@ function renderSelect(params: {
}}
>
<option value=${unset}>Select...</option>
${options.map(
(opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`,
)}
${options.map((opt, idx) => html` <option value=${String(idx)}>${String(opt)}</option> `)}
</select>
</div>
`;
@ -838,8 +832,7 @@ function renderJsonTextarea(params: {
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)}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing} ${renderTags(tags)}
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea${sensitiveState.isRedacted ? " cfg-textarea--redacted" : ""}"
@ -954,41 +947,31 @@ function renderObject(params: {
onPatch,
}),
)}
${
allowExtra
? renderMapField({
schema: additional,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing
}
${allowExtra
? renderMapField({
schema: additional,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing}
`;
// For top-level, don't wrap in collapsible
if (path.length === 1) {
return html`
<div class="cfg-fields">
${fields}
</div>
`;
return html` <div class="cfg-fields">${fields}</div> `;
}
if (!showLabel) {
return html`
<div class="cfg-fields cfg-fields--inline">
${fields}
</div>
`;
return html` <div class="cfg-fields cfg-fields--inline">${fields}</div> `;
}
// Nested objects get collapsible treatment
@ -1002,9 +985,7 @@ function renderObject(params: {
<span class="cfg-object__chevron">${icons.chevronDown}</span>
</summary>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
<div class="cfg-object__content">
${fields}
</div>
<div class="cfg-object__content">${fields}</div>
</details>
`;
}
@ -1078,55 +1059,50 @@ function renderArray(params: {
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${
arr.length === 0
? html`
<div class="cfg-array__empty">No items yet. Click "Add" to create one.</div>
`
: html`
<div class="cfg-array__items">
${arr.map(
(item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<div class="cfg-array__item-content">
${renderNode({
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
${arr.length === 0
? html` <div class="cfg-array__empty">No items yet. Click "Add" to create one.</div> `
: html`
<div class="cfg-array__items">
${arr.map(
(item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<button
type="button"
class="cfg-array__item-remove"
title="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<div class="cfg-array__item-content">
${renderNode({
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
</div>
`,
)}
</div>
`,
)}
</div>
`
}
`}
</div>
`;
}
@ -1199,128 +1175,124 @@ function renderMapField(params: {
</button>
</div>
${
visibleEntries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items">
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-header">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<div class="cfg-map__item-value">
${
anySchema
? html`
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea cfg-textarea--sm${sensitiveState.isRedacted ? " cfg-textarea--redacted" : ""}"
placeholder=${
sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"
${visibleEntries.length === 0
? html` <div class="cfg-map__empty">No custom entries.</div> `
: html`
<div class="cfg-map__items">
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-header">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
rows="2"
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
if (sensitiveState.isRedacted && onToggleSensitivePath) {
onToggleSensitivePath(valuePath);
}
}}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea cfg-textarea--sm${sensitiveState.isRedacted
? " cfg-textarea--redacted"
: ""}"
placeholder=${sensitiveState.isRedacted
? REDACTED_PLACEHOLDER
: "JSON value"}
rows="2"
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
if (sensitiveState.isRedacted && onToggleSensitivePath) {
onToggleSensitivePath(valuePath);
}
}}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path: valuePath,
state: sensitiveState,
disabled,
onToggleSensitivePath,
})}
</div>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
state: sensitiveState,
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
}
</div>
</div>
`;
})}
</div>
`
}
</div>
</div>
`;
})}
</div>
`}
</div>
`;
}

View File

@ -360,16 +360,12 @@ function matchesSearch(params: {
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`
<div class="muted">Schema unavailable.</div>
`;
return html` <div class="muted">Schema unavailable.</div> `;
}
const schema = props.schema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`
<div class="callout danger">Unsupported schema. Use Raw.</div>
`;
return html` <div class="callout danger">Unsupported schema. Use Raw.</div> `;
}
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
@ -449,11 +445,9 @@ export function renderConfigForm(props: ConfigFormProps) {
<span class="config-section-card__icon">${getSectionIcon(params.sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${params.label}</h3>
${
params.description
? html`<p class="config-section-card__desc">${params.description}</p>`
: nothing
}
${params.description
? html`<p class="config-section-card__desc">${params.description}</p>`
: nothing}
</div>
</div>
<div class="config-section-card__content">
@ -477,45 +471,43 @@ export function renderConfigForm(props: ConfigFormProps) {
return html`
<div class="config-form config-form--modern">
${
subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = value[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
return renderSectionCard({
id: `config-section-${sectionKey}-${subsectionKey}`,
sectionKey,
label,
description,
node,
nodeValue: scopedValue,
path: [sectionKey, subsectionKey],
});
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
${subsectionContext
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
const sectionValue = value[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
return renderSectionCard({
id: `config-section-${sectionKey}-${subsectionKey}`,
sectionKey,
label,
description,
node,
nodeValue: scopedValue,
path: [sectionKey, subsectionKey],
});
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return renderSectionCard({
id: `config-section-${key}`,
sectionKey: key,
label: meta.label,
description: meta.description,
node,
nodeValue: value[key],
path: [key],
});
})
}
return renderSectionCard({
id: `config-section-${key}`,
sectionKey: key,
label: meta.label,
description: meta.description,
node,
nodeValue: value[key],
path: [key],
});
})}
</div>
`;
}

View File

@ -578,7 +578,9 @@ function renderAppearanceSection(props: ConfigProps) {
${THEME_OPTIONS.map(
(opt) => html`
<button
class="settings-theme-card ${opt.id === props.theme ? "settings-theme-card--active" : ""}"
class="settings-theme-card ${opt.id === props.theme
? "settings-theme-card--active"
: ""}"
title=${opt.description}
@click=${(e: Event) => {
if (opt.id !== props.theme) {
@ -591,11 +593,11 @@ function renderAppearanceSection(props: ConfigProps) {
>
<span class="settings-theme-card__icon" aria-hidden="true">${opt.icon}</span>
<span class="settings-theme-card__label">${opt.label}</span>
${
opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true">${icons.check}</span>`
: nothing
}
${opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing}
</button>
`,
)}
@ -636,20 +638,20 @@ function renderAppearanceSection(props: ConfigProps) {
<div class="settings-info-row">
<span class="settings-info-row__label">Status</span>
<span class="settings-info-row__value">
<span class="settings-status-dot ${props.connected ? "settings-status-dot--ok" : ""}"></span>
<span
class="settings-status-dot ${props.connected ? "settings-status-dot--ok" : ""}"
></span>
${props.connected ? "Connected" : "Offline"}
</span>
</div>
${
props.assistantName
? html`
${props.assistantName
? html`
<div class="settings-info-row">
<span class="settings-info-row__label">Assistant</span>
<span class="settings-info-row__value">${props.assistantName}</span>
</div>
`
: nothing
}
: nothing}
</div>
</div>
</div>
@ -784,132 +786,102 @@ export function renderConfig(props: ConfigProps) {
<main class="config-main">
<div class="config-actions">
<div class="config-actions__left">
${
showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
title=${formUnsafe ? "Form view can't safely edit some fields" : ""}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
`
: nothing
}
${
hasChanges
? html`
<span class="config-changes-badge"
>${
formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>
`
: html`
<span class="config-status muted">No changes</span>
`
}
${showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
?disabled=${props.schemaLoading || !props.schema}
title=${formUnsafe ? "Form view can't safely edit some fields" : ""}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="config-mode-toggle__btn ${formMode === "raw" ? "active" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
`
: nothing}
${hasChanges
? html`
<span class="config-changes-badge"
>${formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span
>
`
: html` <span class="config-status muted">No changes</span> `}
</div>
<div class="config-actions__right">
${
props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing
}
<button
class="btn btn--sm"
?disabled=${props.loading}
@click=${props.onReload}
>
${props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@click=${props.onOpenFile}
>
${icons.fileText} Open
</button>
`
: nothing}
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
</button>
<button
class="btn btn--sm primary"
?disabled=${!canSave}
@click=${props.onSave}
>
<button class="btn btn--sm primary" ?disabled=${!canSave} @click=${props.onSave}>
${props.saving ? "Saving…" : "Save"}
</button>
<button
class="btn btn--sm"
?disabled=${!canApply}
@click=${props.onApply}
>
<button class="btn btn--sm" ?disabled=${!canApply} @click=${props.onApply}>
${props.applying ? "Applying…" : "Apply"}
</button>
<button
class="btn btn--sm"
?disabled=${!canUpdate}
@click=${props.onUpdate}
>
<button class="btn btn--sm" ?disabled=${!canUpdate} @click=${props.onUpdate}>
${props.updating ? "Updating…" : "Update"}
</button>
</div>
</div>
<div class="config-top-tabs">
${
formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
${formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing}
</div>
`
: nothing
}
</div>
`
: nothing}
<div class="config-top-tabs__scroller" role="tablist" aria-label="Settings sections">
${topTabs.map(
@ -926,41 +898,50 @@ export function renderConfig(props: ConfigProps) {
`,
)}
</div>
</div>
${
validity === "invalid" && !cvs.validityDismissed
? html`
${validity === "invalid" && !cvs.validityDismissed
? html`
<div class="config-validity-warning">
<svg class="config-validity-warning__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
<svg
class="config-validity-warning__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="16"
height="16"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
></path>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span class="config-validity-warning__text">Your configuration is invalid. Some settings may not work as expected.</span>
<span class="config-validity-warning__text"
>Your configuration is invalid. Some settings may not work as expected.</span
>
<button
class="btn btn--sm"
@click=${() => {
cvs.validityDismissed = true;
requestUpdate();
}}
>Don't remind again</button>
>
Don't remind again
</button>
</div>
`
: nothing
}
: nothing}
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${
hasChanges && formMode === "form"
? html`
${hasChanges && formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span
>View ${diff.length} pending
change${diff.length !== 1 ? "s" : ""}</span
>
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
<svg
class="config-diff__chevron"
viewBox="0 0 24 24"
@ -991,63 +972,63 @@ export function renderConfig(props: ConfigProps) {
</div>
</details>
`
: nothing
}
${
activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
: nothing}
${activeSectionMeta && formMode === "form"
? html`
<div class="config-section-hero">
<div class="config-section-hero__icon">
${getSectionIcon(props.activeSection ?? "")}
</div>
<div class="config-section-hero__text">
<div class="config-section-hero__title">
${activeSectionMeta.label}
</div>
${
activeSectionMeta.description
? html`<div class="config-section-hero__desc">
<div class="config-section-hero__title">${activeSectionMeta.label}</div>
${activeSectionMeta.description
? html`<div class="config-section-hero__desc">
${activeSectionMeta.description}
</div>`
: nothing
}
: nothing}
</div>
${
props.activeSection === "env"
? html`
${props.activeSection === "env"
? html`
<button
class="config-env-peek-btn ${envSensitiveVisible ? "config-env-peek-btn--active" : ""}"
class="config-env-peek-btn ${envSensitiveVisible
? "config-env-peek-btn--active"
: ""}"
title=${envSensitiveVisible ? "Hide env values" : "Reveal env values"}
@click=${() => {
cvs.envRevealed = !cvs.envRevealed;
requestUpdate();
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="16"
height="16"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Peek
</button>
`
: nothing
}
: nothing}
</div>
`
: nothing
}
: nothing}
<!-- Form content -->
<div class="config-content">
${
props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${
props.schemaLoading
${props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
@ -1071,39 +1052,38 @@ export function renderConfig(props: ConfigProps) {
toggleSensitivePathReveal(path);
requestUpdate();
},
})
}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${
formUnsafe
? html`
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent. Use Raw mode to edit those
entries.
</div>
`
: nothing
}
})}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${formUnsafe
? html`
<div class="callout info" style="margin-bottom: 12px">
Your config contains fields the form editor can't safely represent. Use
Raw mode to edit those entries.
</div>
`
: nothing}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw config (JSON/JSON5)
${
sensitiveCount > 0
? html`
<span class="pill pill--sm">${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"} ${blurred ? "redacted" : "visible"}</span>
${sensitiveCount > 0
? html`
<span class="pill pill--sm"
>${sensitiveCount} secret${sensitiveCount === 1 ? "" : "s"}
${blurred ? "redacted" : "visible"}</span
>
<button
class="btn btn--icon config-raw-toggle ${blurred ? "" : "active"}"
title=${
blurred ? "Reveal sensitive values" : "Hide sensitive values"
}
title=${blurred
? "Reveal sensitive values"
: "Hide sensitive values"}
aria-label="Toggle raw config redaction"
aria-pressed=${!blurred}
@click=${() => {
@ -1114,42 +1094,34 @@ export function renderConfig(props: ConfigProps) {
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
: nothing}
</span>
${
blurred
? html`
<div class="callout info" style="margin-top: 12px">
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"} hidden. Use the
reveal button above to edit the raw config.
</div>
`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@input=${(e: Event) => {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`
}
${blurred
? html`
<div class="callout info" style="margin-top: 12px">
${sensitiveCount} sensitive value${sensitiveCount === 1 ? "" : "s"}
hidden. Use the reveal button above to edit the raw config.
</div>
`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@input=${(e: Event) => {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`}
</div>
`;
})()
}
})()}
</div>
${
props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">
${JSON.stringify(props.issues, null, 2)}</pre
>
${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
<pre class="code-block">${JSON.stringify(props.issues, null, 2)}</pre>
</div>`
: nothing
}
: nothing}
</main>
</div>
`;

File diff suppressed because it is too large Load Diff

View File

@ -48,14 +48,12 @@ export function renderDebug(props: DebugProps) {
<div class="stack" style="margin-top: 12px;">
<div>
<div class="muted">Status</div>
${
securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
${securitySummary
? html`<div class="callout ${securityTone}" style="margin-top: 8px;">
Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run
<span class="mono">openclaw security audit --deep</span> for details.
</div>`
: nothing
}
: nothing}
<pre class="code-block">${JSON.stringify(props.status ?? {}, null, 2)}</pre>
</div>
<div>
@ -77,15 +75,12 @@ export function renderDebug(props: DebugProps) {
<span>Method</span>
<select
.value=${props.callMethod}
@change=${(e: Event) => props.onCallMethodChange((e.target as HTMLSelectElement).value)}
@change=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLSelectElement).value)}
>
${
!props.callMethod
? html`
<option value="" disabled>Select a method</option>
`
: nothing
}
${!props.callMethod
? html` <option value="" disabled>Select a method…</option> `
: nothing}
${props.methods.map((m) => html`<option value=${m}>${m}</option>`)}
</select>
</label>
@ -102,40 +97,29 @@ export function renderDebug(props: DebugProps) {
<div class="row" style="margin-top: 12px;">
<button class="btn primary" @click=${props.onCall}>Call</button>
</div>
${
props.callError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.callError}
</div>`
: nothing
}
${
props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing
}
${props.callError
? html`<div class="callout danger" style="margin-top: 12px;">${props.callError}</div>`
: nothing}
${props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing}
</div>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Models</div>
<div class="card-sub">Catalog from models.list.</div>
<pre class="code-block" style="margin-top: 12px;">${JSON.stringify(
props.models ?? [],
null,
2,
)}</pre>
<pre class="code-block" style="margin-top: 12px;">
${JSON.stringify(props.models ?? [], null, 2)}</pre
>
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Event Log</div>
<div class="card-sub">Latest gateway events.</div>
${
props.eventLog.length === 0
? html`
<div class="muted" style="margin-top: 12px">No events yet.</div>
`
: html`
${props.eventLog.length === 0
? html` <div class="muted" style="margin-top: 12px">No events yet.</div> `
: html`
<div class="list debug-event-log" style="margin-top: 12px;">
${props.eventLog.map(
(evt) => html`
@ -145,16 +129,15 @@ export function renderDebug(props: DebugProps) {
<div class="list-sub">${new Date(evt.ts).toLocaleTimeString()}</div>
</div>
<div class="list-meta debug-event-log__meta">
<pre class="code-block debug-event-log__payload">${formatEventPayload(
evt.payload,
)}</pre>
<pre class="code-block debug-event-log__payload">
${formatEventPayload(evt.payload)}</pre
>
</div>
</div>
`,
)}
</div>
`
}
`}
</section>
`;
}

View File

@ -39,27 +39,20 @@ export function renderExecApprovalPrompt(state: AppViewState) {
<div class="exec-approval-title">Exec approval needed</div>
<div class="exec-approval-sub">${remaining}</div>
</div>
${
queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing
}
${queueCount > 1
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
: nothing}
</div>
<div class="exec-approval-command mono">${request.command}</div>
<div class="exec-approval-meta">
${renderMetaRow("Host", request.host)}
${renderMetaRow("Agent", request.agentId)}
${renderMetaRow("Session", request.sessionKey)}
${renderMetaRow("CWD", request.cwd)}
${renderMetaRow("Host", request.host)} ${renderMetaRow("Agent", request.agentId)}
${renderMetaRow("Session", request.sessionKey)} ${renderMetaRow("CWD", request.cwd)}
${renderMetaRow("Resolved", request.resolvedPath)}
${renderMetaRow("Security", request.security)}
${renderMetaRow("Ask", request.ask)}
${renderMetaRow("Security", request.security)} ${renderMetaRow("Ask", request.ask)}
</div>
${
state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing
}
${state.execApprovalError
? html`<div class="exec-approval-error">${state.execApprovalError}</div>`
: nothing}
<div class="exec-approval-actions">
<button
class="btn primary"

View File

@ -21,18 +21,10 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
Only confirm if you trust this URL. Malicious URLs can compromise your system.
</div>
<div class="exec-approval-actions">
<button
class="btn primary"
@click=${() => state.handleGatewayUrlConfirm()}
>
<button class="btn primary" @click=${() => state.handleGatewayUrlConfirm()}>
Confirm
</button>
<button
class="btn"
@click=${() => state.handleGatewayUrlCancel()}
>
Cancel
</button>
<button class="btn" @click=${() => state.handleGatewayUrlCancel()}>Cancel</button>
</div>
</div>
</div>

View File

@ -42,28 +42,16 @@ export function renderInstances(props: InstancesProps) {
</button>
</div>
</div>
${
props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing
}
${
props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.statusMessage}
</div>`
: nothing
}
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${props.lastError}</div>`
: nothing}
${props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">${props.statusMessage}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${
props.entries.length === 0
? html`
<div class="muted">No instances reported yet.</div>
`
: props.entries.map((entry) => renderEntry(entry, masked))
}
${props.entries.length === 0
? html` <div class="muted">No instances reported yet.</div> `
: props.entries.map((entry) => renderEntry(entry, masked))}
</div>
</section>
`;
@ -89,7 +77,8 @@ function renderEntry(entry: PresenceEntry, masked: boolean) {
<span class="${masked ? "redacted" : ""}">${host}</span>
</div>
<div class="list-sub">
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode} ${entry.version ?? ""}
${ip ? html`<span class="${masked ? "redacted" : ""}">${ip}</span> ` : nothing}${mode}
${entry.version ?? ""}
</div>
<div class="chip-row">
<span class="chip">${mode}</span>
@ -97,11 +86,9 @@ function renderEntry(entry: PresenceEntry, masked: boolean) {
${scopesLabel ? html`<span class="chip">${scopesLabel}</span>` : nothing}
${entry.platform ? html`<span class="chip">${entry.platform}</span>` : nothing}
${entry.deviceFamily ? html`<span class="chip">${entry.deviceFamily}</span>` : nothing}
${
entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing
}
${entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing}
${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing}
</div>
</div>

View File

@ -95,20 +95,15 @@ export function renderLoginGate(state: AppViewState) {
</button>
</div>
</label>
<button
class="btn primary login-gate__connect"
@click=${() => state.connect()}
>
<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>`
: ""
}
${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">
@ -122,7 +117,8 @@ export function renderLoginGate(state: AppViewState) {
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
>${t("overview.connection.docsLink")}</a
>
</div>
</div>
</div>

View File

@ -114,32 +114,25 @@ export function renderLogs(props: LogsProps) {
)}
</div>
${
props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
: nothing
}
${
props.truncated
? html`
<div class="callout" style="margin-top: 10px">Log output truncated; showing latest chunk.</div>
`
: nothing
}
${
props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
: nothing
}
${props.file
? html`<div class="muted" style="margin-top: 10px;">File: ${props.file}</div>`
: nothing}
${props.truncated
? html`
<div class="callout" style="margin-top: 10px">
Log output truncated; showing latest chunk.
</div>
`
: nothing}
${props.error
? html`<div class="callout danger" style="margin-top: 10px;">${props.error}</div>`
: nothing}
<div class="log-stream" style="margin-top: 12px;" @scroll=${props.onScroll}>
${
filtered.length === 0
? html`
<div class="muted" style="padding: 12px">No log entries.</div>
`
: filtered.map(
(entry) => html`
${filtered.length === 0
? html` <div class="muted" style="padding: 12px">No log entries.</div> `
: filtered.map(
(entry) => html`
<div class="log-row">
<div class="log-time mono">${formatTime(entry.time)}</div>
<div class="log-level ${entry.level ?? ""}">${entry.level ?? ""}</div>
@ -147,8 +140,7 @@ export function renderLogs(props: LogsProps) {
<div class="log-message mono">${entry.message ?? entry.raw}</div>
</div>
`,
)
}
)}
</div>
</section>
`;

View File

@ -15,25 +15,21 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<div class="sidebar-panel">
<div class="sidebar-header">
<div class="sidebar-title">Tool Output</div>
<button @click=${props.onClose} class="btn" title="Close sidebar">
${icons.x}
</button>
<button @click=${props.onClose} class="btn" title="Close sidebar">${icons.x}</button>
</div>
<div class="sidebar-content">
${
props.error
? html`
${props.error
? html`
<div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text
</button>
`
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`
<div class="muted">No content available</div>
`
}
: props.content
? html`<div class="sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(props.content))}
</div>`
: html` <div class="muted">No content available</div> `}
</div>
</div>
`;

View File

@ -211,25 +211,19 @@ export function renderExecApprovals(state: ExecApprovalsState) {
</div>
${renderExecApprovalsTarget(state)}
${
!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
: html`
${renderExecApprovalsTabs(state)}
${renderExecApprovalsPolicy(state)}
${
state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)
}
`
}
: html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
`}
</section>
`;
}
@ -242,9 +236,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
</div>
<div class="list-sub">Gateway edits local approvals; node edits the selected node.</div>
</div>
<div class="list-meta">
<label class="field">
@ -266,9 +258,8 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${
state.target === "node"
? html`
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
@ -282,27 +273,19 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
html`<option value=${node.id} ?selected=${nodeValue === node.id}>
${node.label}
</option>`,
)}
</select>
</label>
`
: nothing
}
: nothing}
</div>
</div>
${
state.target === "node" && !hasNodes
? html`
<div class="muted">No nodes advertise exec approvals yet.</div>
`
: nothing
}
${state.target === "node" && !hasNodes
? html` <div class="muted">No nodes advertise exec approvals yet.</div> `
: nothing}
</div>
`;
}
@ -313,7 +296,9 @@ function renderExecApprovalsTabs(state: ExecApprovalsState) {
<span class="label">Scope</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? "active"
: ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
@ -374,19 +359,14 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
${!isDefaults
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
Use default (${defaults.security})
</option>`
: nothing
}
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${securityValue === option.value}
>
html`<option value=${option.value} ?selected=${securityValue === option.value}>
${option.label}
</option>`,
)}
@ -417,19 +397,14 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
${!isDefaults
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
Use default (${defaults.ask})
</option>`
: nothing
}
: nothing}
${ASK_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askValue === option.value}
>
html`<option value=${option.value} ?selected=${askValue === option.value}>
${option.label}
</option>`,
)}
@ -442,11 +417,9 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Ask fallback</div>
<div class="list-sub">
${
isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`
}
${isDefaults
? "Applied when the UI prompt is unavailable."
: `Default: ${defaults.askFallback}.`}
</div>
</div>
<div class="list-meta">
@ -464,19 +437,14 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}
}}
>
${
!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
${!isDefaults
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
Use default (${defaults.askFallback})
</option>`
: nothing
}
: nothing}
${SECURITY_OPTIONS.map(
(option) =>
html`<option
value=${option.value}
?selected=${askFallbackValue === option.value}
>
html`<option value=${option.value} ?selected=${askFallbackValue === option.value}>
${option.label}
</option>`,
)}
@ -489,13 +457,11 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Auto-allow skill CLIs</div>
<div class="list-sub">
${
isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`
}
${isDefaults
? "Allow skill executables listed by the Gateway."
: autoIsDefault
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
: `Override (${autoEffective ? "on" : "off"}).`}
</div>
</div>
<div class="list-meta">
@ -511,17 +477,15 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
}}
/>
</label>
${
!isDefaults && !autoIsDefault
? html`<button
${!isDefaults && !autoIsDefault
? html`<button
class="btn btn--sm"
?disabled=${state.disabled}
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
>
Use default
</button>`
: nothing
}
: nothing}
</div>
</div>
</div>
@ -549,13 +513,9 @@ function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
</button>
</div>
<div class="list" style="margin-top: 12px;">
${
entries.length === 0
? html`
<div class="muted">No allowlist entries yet.</div>
`
: entries.map((entry, index) => renderAllowlistEntry(state, entry, index))
}
${entries.length === 0
? html` <div class="muted">No allowlist entries yet.</div> `
: entries.map((entry, index) => renderAllowlistEntry(state, entry, index))}
</div>
`;
}

View File

@ -50,9 +50,7 @@ export function renderNodes(props: NodesProps) {
const bindingState = resolveBindingsState(props);
const approvalsState = resolveExecApprovalsState(props);
return html`
${renderExecApprovals(approvalsState)}
${renderBindings(bindingState)}
${renderDevices(props)}
${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)} ${renderDevices(props)}
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
@ -64,13 +62,9 @@ export function renderNodes(props: NodesProps) {
</button>
</div>
<div class="list" style="margin-top: 16px;">
${
props.nodes.length === 0
? html`
<div class="muted">No nodes found.</div>
`
: props.nodes.map((n) => renderNode(n))
}
${props.nodes.length === 0
? html` <div class="muted">No nodes found.</div> `
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
`;
@ -91,35 +85,25 @@ function renderDevices(props: NodesProps) {
${props.devicesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${
props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing
}
${props.devicesError
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
: nothing}
<div class="list" style="margin-top: 16px;">
${
pending.length > 0
? html`
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing
}
${
paired.length > 0
? html`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing
}
${
pending.length === 0 && paired.length === 0
? html`
<div class="muted">No paired devices.</div>
`
: nothing
}
: nothing}
${pending.length === 0 && paired.length === 0
? html` <div class="muted">No paired devices.</div> `
: nothing}
</div>
</section>
`;
@ -167,18 +151,14 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${
tokens.length === 0
? html`
<div class="muted" style="margin-top: 6px">Tokens: none</div>
`
: html`
${tokens.length === 0
? html` <div class="muted" style="margin-top: 6px">Tokens: none</div> `
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
`
}
`}
</div>
</div>
`;
@ -200,18 +180,16 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
>
Rotate
</button>
${
token.revokedAtMs
? nothing
: html`
${token.revokedAtMs
? nothing
: html`
<button
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
</button>
`
}
`}
</div>
</div>
`;
@ -287,25 +265,21 @@ function renderBindings(state: BindingState) {
</button>
</div>
${
state.formMode === "raw"
? html`
<div class="callout warn" style="margin-top: 12px">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>
`
: nothing
}
${
!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
${state.formMode === "raw"
? html`
<div class="callout warn" style="margin-top: 12px">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
</div>
`
: nothing}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
</button>
</div>`
: html`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
@ -326,35 +300,23 @@ function renderBindings(state: BindingState) {
<option value="" ?selected=${defaultValue === ""}>Any node</option>
${state.nodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${defaultValue === node.id}
>
html`<option value=${node.id} ?selected=${defaultValue === node.id}>
${node.label}
</option>`,
)}
</select>
</label>
${
!supportsBinding
? html`
<div class="muted">No nodes with system.run available.</div>
`
: nothing
}
${!supportsBinding
? html` <div class="muted">No nodes with system.run available.</div> `
: nothing}
</div>
</div>
${
state.agents.length === 0
? html`
<div class="muted">No agents found.</div>
`
: state.agents.map((agent) => renderAgentBinding(agent, state))
}
${state.agents.length === 0
? html` <div class="muted">No agents found.</div> `
: state.agents.map((agent) => renderAgentBinding(agent, state))}
</div>
`
}
`}
</section>
`;
}
@ -369,11 +331,9 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
<div class="list-title">${label}</div>
<div class="list-sub">
${agent.isDefault ? "default agent" : "agent"} ·
${
bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`
}
${bindingValue === "__default__"
? `uses default (${state.defaultBinding ?? "any"})`
: `override: ${agent.binding}`}
</div>
</div>
<div class="list-meta">
@ -392,10 +352,7 @@ function renderAgentBinding(agent: BindingAgent, state: BindingState) {
</option>
${state.nodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${bindingValue === node.id}
>
html`<option value=${node.id} ?selected=${bindingValue === node.id}>
${node.label}
</option>`,
)}

View File

@ -42,16 +42,15 @@ export function renderOverviewAttention(props: OverviewAttentionProps) {
<div class="ov-attention-title">${item.title}</div>
<div class="muted">${item.description}</div>
</div>
${
item.href
? html`<a
${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
}
>${t("common.docs")}</a
>`
: nothing}
</div>
`,
)}

View File

@ -134,29 +134,29 @@ export function renderOverviewCards(props: OverviewCardsProps) {
const sessions = props.sessionsResult?.sessions.slice(0, 5) ?? [];
return html`
<section class="ov-cards">
${cards.map((c) => renderStatCard(c, props.onNavigate))}
</section>
<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
}
${sessions.length > 0
? html`
<section class="ov-recent">
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
<ul class="ov-recent__list">
${sessions.map(
(s) => html`
<li class="ov-recent__row">
<span class="ov-recent__key"
>${blurDigits(s.displayName || s.label || s.key)}</span
>
<span class="ov-recent__model">${s.model ?? ""}</span>
<span class="ov-recent__time"
>${s.updatedAt ? formatRelativeTimestamp(s.updatedAt) : ""}</span
>
</li>
`,
)}
</ul>
</section>
`
: nothing}
`;
}

View File

@ -28,11 +28,11 @@ export function renderOverviewEventLog(props: OverviewEventLogProps) {
<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
}
${entry.payload
? html`<span class="ov-event-log-payload muted"
>${formatEventPayload(entry.payload).slice(0, 120)}</span
>`
: nothing}
</div>
`,
)}

View File

@ -36,7 +36,8 @@ export function renderOverviewLogTail(props: OverviewLogTailProps) {
e.stopPropagation();
props.onRefreshLogs();
}}
>${icons.loader}</span>
>${icons.loader}</span
>
</summary>
<pre class="ov-log-tail-content">${displayLines}</pre>
</details>

View File

@ -84,9 +84,7 @@ export function renderOverview(props: OverviewProps) {
<span class="mono">openclaw devices list</span><br />
<span class="mono">openclaw devices approve &lt;requestId&gt;</span>
</div>
<div style="margin-top: 6px; font-size: 12px;">
${t("overview.pairing.mobileHint")}
</div>
<div style="margin-top: 6px; font-size: 12px;">${t("overview.pairing.mobileHint")}</div>
<div style="margin-top: 6px">
<a
class="session-link"
@ -165,7 +163,9 @@ export function renderOverview(props: OverviewProps) {
<div class="muted" style="margin-top: 8px">
${t("overview.insecure.hint", { url: "http://127.0.0.1:18789" })}
<div style="margin-top: 6px">
${t("overview.insecure.stayHttp", { config: "gateway.controlUi.allowInsecureAuth: true" })}
${t("overview.insecure.stayHttp", {
config: "gateway.controlUi.allowInsecureAuth: true",
})}
</div>
<div style="margin-top: 6px">
<a
@ -215,10 +215,9 @@ export function renderOverview(props: OverviewProps) {
placeholder="ws://100.x.y.z:18789"
/>
</label>
${
isTrustedProxy
? ""
: html`
${isTrustedProxy
? ""
: html`
<label class="field">
<span>${t("overview.access.token")}</span>
<div style="display: flex; align-items: center; gap: 8px;">
@ -273,8 +272,7 @@ export function renderOverview(props: OverviewProps) {
</button>
</div>
</label>
`
}
`}
<label class="field">
<span>${t("overview.access.sessionKey")}</span>
<input
@ -307,34 +305,41 @@ export function renderOverview(props: OverviewProps) {
<div class="row" style="margin-top: 14px;">
<button class="btn" @click=${() => props.onConnect()}>${t("common.connect")}</button>
<button class="btn" @click=${() => props.onRefresh()}>${t("common.refresh")}</button>
<span class="muted">${
isTrustedProxy ? t("overview.access.trustedProxy") : t("overview.access.connectHint")
}</span>
<span class="muted"
>${isTrustedProxy
? t("overview.access.trustedProxy")
: t("overview.access.connectHint")}</span
>
</div>
${
!props.connected
? html`
<div class="login-gate__help" style="margin-top: 16px;">
<div class="login-gate__help-title">${t("overview.connection.title")}</div>
<ol class="login-gate__steps">
<li>${t("overview.connection.step1")}<code>openclaw gateway run</code></li>
<li>${t("overview.connection.step2")}<code>openclaw dashboard --no-open</code></li>
<li>${t("overview.connection.step3")}</li>
<li>${t("overview.connection.step4")}<code>openclaw doctor --generate-gateway-token</code></li>
</ol>
<div class="login-gate__docs">
${t("overview.connection.docsHint")}
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
target="_blank"
rel="noreferrer"
>${t("overview.connection.docsLink")}</a>
</div>
${!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>
`
: nothing
}
</div>
`
: nothing}
</div>
<div class="card">
@ -358,24 +363,22 @@ export function renderOverview(props: OverviewProps) {
<div class="stat">
<div class="stat-label">${t("overview.snapshot.lastChannelsRefresh")}</div>
<div class="stat-value">
${props.lastChannelsRefresh ? formatRelativeTimestamp(props.lastChannelsRefresh) : t("common.na")}
${props.lastChannelsRefresh
? formatRelativeTimestamp(props.lastChannelsRefresh)
: t("common.na")}
</div>
</div>
</div>
${
props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
${props.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div>
${pairingHint ?? ""}
${authHint ?? ""}
${insecureContextHint ?? ""}
${pairingHint ?? ""} ${authHint ?? ""} ${insecureContextHint ?? ""}
</div>`
: html`
<div class="callout" style="margin-top: 14px">
${t("overview.snapshot.channelsHint")}
</div>
`
}
: html`
<div class="callout" style="margin-top: 14px">
${t("overview.snapshot.channelsHint")}
</div>
`}
</div>
</section>
@ -390,7 +393,6 @@ export function renderOverview(props: OverviewProps) {
presenceCount: props.presenceCount,
onNavigate: props.onNavigate,
})}
${renderOverviewAttention({ items: props.attentionItems })}
<div class="ov-section-divider"></div>
@ -399,12 +401,10 @@ export function renderOverview(props: OverviewProps) {
${renderOverviewEventLog({
events: props.eventLog,
})}
${renderOverviewLogTail({
lines: props.overviewLogLines,
onRefreshLogs: props.onRefreshLogs,
})}
</div>
`;
}

View File

@ -215,7 +215,11 @@ export function renderSessions(props: SessionsProps) {
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">${props.result ? `Store: ${props.result.path}` : "Active session keys and per-session overrides."}</div>
<div class="card-sub">
${props.result
? `Store: ${props.result.path}`
: "Active session keys and per-session overrides."}
</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
@ -282,11 +286,9 @@ export function renderSessions(props: SessionsProps) {
</label>
</div>
${
props.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
: nothing
}
${props.error
? html`<div class="callout danger" style="margin-bottom: 12px;">${props.error}</div>`
: nothing}
<div class="data-table-wrapper">
<div class="data-table-toolbar">
@ -300,40 +302,34 @@ export function renderSessions(props: SessionsProps) {
</div>
</div>
${
props.selectedKeys.size > 0
? html`
<div class="data-table-bulk-bar">
<span>${props.selectedKeys.size} selected</span>
<button
class="btn btn--sm"
@click=${props.onDeselectAll}
>
Unselect
</button>
<button
class="btn btn--sm danger"
?disabled=${props.loading}
@click=${props.onDeleteSelected}
>
${icons.trash} Delete
</button>
</div>
`
: nothing
}
${props.selectedKeys.size > 0
? html`
<div class="data-table-bulk-bar">
<span>${props.selectedKeys.size} selected</span>
<button class="btn btn--sm" @click=${props.onDeselectAll}>Unselect</button>
<button
class="btn btn--sm danger"
?disabled=${props.loading}
@click=${props.onDeleteSelected}
>
${icons.trash} Delete
</button>
</div>
`
: nothing}
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th class="data-table-checkbox-col">
${
paginated.length > 0
? html`<input
${paginated.length > 0
? html`<input
type="checkbox"
.checked=${paginated.length > 0 && paginated.every((r) => props.selectedKeys.has(r.key))}
.indeterminate=${paginated.some((r) => props.selectedKeys.has(r.key)) && !paginated.every((r) => props.selectedKeys.has(r.key))}
.checked=${paginated.length > 0 &&
paginated.every((r) => props.selectedKeys.has(r.key))}
.indeterminate=${paginated.some((r) => props.selectedKeys.has(r.key)) &&
!paginated.every((r) => props.selectedKeys.has(r.key))}
@change=${() => {
const allSelected = paginated.every((r) => props.selectedKeys.has(r.key));
if (allSelected) {
@ -344,13 +340,11 @@ export function renderSessions(props: SessionsProps) {
}}
aria-label="Select all on page"
/>`
: nothing
}
: nothing}
</th>
${sortHeader("key", "Key", "data-table-key-col")}
<th>Label</th>
${sortHeader("kind", "Kind")}
${sortHeader("updated", "Updated")}
${sortHeader("kind", "Kind")} ${sortHeader("updated", "Updated")}
${sortHeader("tokens", "Tokens")}
<th>Thinking</th>
<th>Fast</th>
@ -359,65 +353,61 @@ export function renderSessions(props: SessionsProps) {
</tr>
</thead>
<tbody>
${
paginated.length === 0
? html`
<tr>
<td colspan="10" style="text-align: center; padding: 48px 16px; color: var(--muted)">
No sessions found.
</td>
</tr>
`
: paginated.map((row) =>
renderRow(
row,
props.basePath,
props.onPatch,
props.selectedKeys.has(row.key),
props.onToggleSelect,
props.loading,
props.onNavigateToChat,
),
)
}
${paginated.length === 0
? html`
<tr>
<td
colspan="10"
style="text-align: center; padding: 48px 16px; color: var(--muted)"
>
No sessions found.
</td>
</tr>
`
: paginated.map((row) =>
renderRow(
row,
props.basePath,
props.onPatch,
props.selectedKeys.has(row.key),
props.onToggleSelect,
props.loading,
props.onNavigateToChat,
),
)}
</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>
${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>
`
: nothing
}
<div class="data-table-pagination__controls">
<select
style="height: 32px; padding: 0 8px; font-size: 13px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card);"
.value=${String(props.pageSize)}
@change=${(e: Event) =>
props.onPageSizeChange(Number((e.target as HTMLSelectElement).value))}
>
${PAGE_SIZES.map((s) => html`<option value=${s}>${s} per page</option>`)}
</select>
<button ?disabled=${page <= 0} @click=${() => props.onPageChange(page - 1)}>
Previous
</button>
<button
?disabled=${page >= totalPages - 1}
@click=${() => props.onPageChange(page + 1)}
>
Next
</button>
</div>
</div>
`
: nothing}
</div>
</section>
`;
@ -477,35 +467,32 @@ function renderRow(
</td>
<td class="data-table-key-col">
<div class="mono session-key-cell">
${
canLink
? html`<a
href=${chatUrl}
class="session-link"
@click=${(e: MouseEvent) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
if (onNavigateToChat) {
e.preventDefault();
onNavigateToChat(row.key);
}
}}
>${row.key}</a>`
: row.key
}
${
showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing
}
${canLink
? html`<a
href=${chatUrl}
class="session-link"
@click=${(e: MouseEvent) => {
if (
e.defaultPrevented ||
e.button !== 0 ||
e.metaKey ||
e.ctrlKey ||
e.shiftKey ||
e.altKey
) {
return;
}
if (onNavigateToChat) {
e.preventDefault();
onNavigateToChat(row.key);
}
}}
>${row.key}</a
>`
: row.key}
${showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing}
</div>
</td>
<td>

View File

@ -30,23 +30,11 @@ export function renderSkillStatusChips(params: {
return html`
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
${
showBundledBadge
? html`
<span class="chip">bundled</span>
`
: nothing
}
${showBundledBadge ? html` <span class="chip">bundled</span> ` : nothing}
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">
${skill.eligible ? "eligible" : "blocked"}
</span>
${
skill.disabled
? html`
<span class="chip chip-warn">disabled</span>
`
: nothing
}
${skill.disabled ? html` <span class="chip chip-warn">disabled</span> ` : nothing}
</div>
`;
}

View File

@ -113,7 +113,11 @@ export function renderSkills(props: SkillsProps) {
<div class="card-title">Skills</div>
<div class="card-sub">Installed skills and their status.</div>
</div>
<button class="btn" ?disabled=${props.loading || !props.connected} @click=${props.onRefresh}>
<button
class="btn"
?disabled=${props.loading || !props.connected}
@click=${props.onRefresh}
>
${props.loading ? "Loading\u2026" : "Refresh"}
</button>
</div>
@ -131,14 +135,18 @@ export function renderSkills(props: SkillsProps) {
)}
</div>
<div class="filters" style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
<div
class="filters"
style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 12px;"
>
<a
class="btn btn--sm"
href="https://clawhub.com"
target="_blank"
rel="noreferrer"
title="Browse skills on ClawHub"
>Browse Skills Store</a>
>Browse Skills Store</a
>
<label class="field" style="flex: 1; min-width: 180px;">
<input
.value=${props.filter}
@ -151,24 +159,18 @@ export function renderSkills(props: SkillsProps) {
<div class="muted">${filtered.length} shown</div>
</div>
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing
}
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">
${
!props.connected && !props.report
? "Not connected to gateway."
: "No skills found."
}
</div>
`
: html`
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">
${!props.connected && !props.report
? "Not connected to gateway."
: "No skills found."}
</div>
`
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) => {
return html`
@ -184,8 +186,7 @@ export function renderSkills(props: SkillsProps) {
`;
})}
</div>
`
}
`}
</section>
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing}
@ -197,10 +198,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const dotClass = skillStatusClass(skill);
return html`
<div
class="list-item list-item-clickable"
@click=${() => props.onDetailOpen(skill.skillKey)}
>
<div class="list-item list-item-clickable" @click=${() => props.onDetailOpen(skill.skillKey)}>
<div class="list-main">
<div class="list-title" style="display: flex; align-items: center; gap: 8px;">
<span class="statusDot ${dotClass}"></span>
@ -209,11 +207,11 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
</div>
<div class="list-meta" style="display: flex; align-items: center; justify-content: flex-end; gap: 10px;">
<label
class="skill-toggle-wrap"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="list-meta"
style="display: flex; align-items: center; justify-content: flex-end; gap: 10px;"
>
<label class="skill-toggle-wrap" @click=${(e: Event) => e.stopPropagation()}>
<input
type="checkbox"
class="skill-toggle"
@ -240,14 +238,21 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
const reasons = computeSkillReasons(skill);
return html`
<dialog class="md-preview-dialog" open @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains("md-preview-dialog")) {
props.onDetailClose();
}
}}>
<dialog
class="md-preview-dialog"
open
@click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains("md-preview-dialog")) {
props.onDetailClose();
}
}}
>
<div class="md-preview-dialog__panel">
<div class="md-preview-dialog__header">
<div class="md-preview-dialog__title" style="display: flex; align-items: center; gap: 8px;">
<div
class="md-preview-dialog__title"
style="display: flex; align-items: center; gap: 8px;"
>
<span class="statusDot ${skillStatusClass(skill)}"></span>
${skill.emoji ? html`<span style="font-size: 18px;">${skill.emoji}</span>` : nothing}
<span>${skill.name}</span>
@ -256,30 +261,28 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
</div>
<div class="md-preview-dialog__body" style="display: grid; gap: 16px;">
<div>
<div style="font-size: 14px; line-height: 1.5; color: var(--text);">${skill.description}</div>
<div style="font-size: 14px; line-height: 1.5; color: var(--text);">
${skill.description}
</div>
${renderSkillStatusChips({ skill, showBundledBadge })}
</div>
${
missing.length > 0
? html`
<div class="callout" style="border-color: var(--warn-subtle); background: var(--warn-subtle); color: var(--warn);">
${missing.length > 0
? html`
<div
class="callout"
style="border-color: var(--warn-subtle); background: var(--warn-subtle); color: var(--warn);"
>
<div style="font-weight: 600; margin-bottom: 4px;">Missing requirements</div>
<div>${missing.join(", ")}</div>
</div>
`
: nothing
}
${
reasons.length > 0
? html`
<div class="muted" style="font-size: 13px;">
Reason: ${reasons.join(", ")}
</div>
: nothing}
${reasons.length > 0
? html`
<div class="muted" style="font-size: 13px;">Reason: ${reasons.join(", ")}</div>
`
: nothing
}
: nothing}
<div style="display: flex; align-items: center; gap: 12px;">
<label class="skill-toggle-wrap">
@ -294,35 +297,32 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
<span style="font-size: 13px; font-weight: 500;">
${skill.disabled ? "Disabled" : "Enabled"}
</span>
${
canInstall
? html`<button
${canInstall
? html`<button
class="btn"
?disabled=${busy}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing\u2026" : skill.install[0].label}
</button>`
: nothing
}
: nothing}
</div>
${
message
? html`<div
class="callout ${message.kind === "error" ? "danger" : "success"}"
>
${message
? html`<div class="callout ${message.kind === "error" ? "danger" : "success"}">
${message.message}
</div>`
: nothing
}
${
skill.primaryEnv
? html`
: nothing}
${skill.primaryEnv
? html`
<div style="display: grid; gap: 8px;">
<div class="field">
<span>API key <span class="muted" style="font-weight: normal; font-size: 0.88em;">(${skill.primaryEnv})</span></span>
<span
>API key
<span class="muted" style="font-weight: normal; font-size: 0.88em;"
>(${skill.primaryEnv})</span
></span
>
<input
type="password"
.value=${apiKey}
@ -334,8 +334,11 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
const href = safeExternalHref(skill.homepage);
return href
? html`<div class="muted" style="font-size: 13px;">
Get your key: <a href="${href}" target="_blank" rel="noopener noreferrer">${skill.homepage}</a>
</div>`
Get your key:
<a href="${href}" target="_blank" rel="noopener noreferrer"
>${skill.homepage}</a
>
</div>`
: nothing;
})()}
<button
@ -347,16 +350,21 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
</button>
</div>
`
: nothing
}
: nothing}
<div style="border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 6px; font-size: 12px; color: var(--muted);">
<div
style="border-top: 1px solid var(--border); padding-top: 12px; display: grid; gap: 6px; font-size: 12px; color: var(--muted);"
>
<div><span style="font-weight: 600;">Source:</span> ${skill.source}</div>
<div style="font-family: var(--mono); word-break: break-all;">${skill.filePath}</div>
${(() => {
const safeHref = safeExternalHref(skill.homepage);
return safeHref
? html`<div><a href="${safeHref}" target="_blank" rel="noopener noreferrer">${skill.homepage}</a></div>`
? html`<div>
<a href="${safeHref}" target="_blank" rel="noopener noreferrer"
>${skill.homepage}</a
>
</div>`
: nothing;
})()}
</div>

View File

@ -197,7 +197,9 @@ function renderUsageMosaic(
<div class="usage-mosaic-title">${t("usage.mosaic.title")}</div>
<div class="usage-mosaic-sub">${t("usage.mosaic.subtitleEmpty")}</div>
</div>
<div class="usage-mosaic-total">${formatTokens(0)} ${t("usage.metrics.tokens").toLowerCase()}</div>
<div class="usage-mosaic-total">
${formatTokens(0)} ${t("usage.metrics.tokens").toLowerCase()}
</div>
</div>
<div class="usage-empty-block usage-empty-block--compact">
${t("usage.mosaic.noTimelineData")}

View File

@ -55,9 +55,7 @@ function renderSessionSummary(
) {
const usage = filteredUsage || session.usage;
if (!usage) {
return html`
<div class="usage-empty-block">${t("usage.details.noUsageData")}</div>
`;
return html` <div class="usage-empty-block">${t("usage.details.noUsageData")}</div> `;
}
const formatTs = (ts?: number): string =>
@ -116,7 +114,11 @@ function renderSessionSummary(
})) ?? [];
return html`
${badges.length > 0 ? html`<div class="usage-badges">${badges.map((b) => html`<span class="usage-badge">${b}</span>`)}</div>` : nothing}
${badges.length > 0
? html`<div class="usage-badges">
${badges.map((b) => html`<span class="usage-badge">${b}</span>`)}
</div>`
: nothing}
<div class="session-summary-grid">
<div class="stat session-summary-card">
<div class="session-summary-title">${t("usage.overview.messages")}</div>
@ -141,9 +143,12 @@ function renderSessionSummary(
<div class="stat session-summary-card">
<div class="session-summary-title">${t("usage.details.duration")}</div>
<div class="stat-value session-summary-value">
${formatDurationCompact(usage.durationMs, { spaced: true }) ?? t("usage.common.emptyValue")}
${formatDurationCompact(usage.durationMs, { spaced: true }) ??
t("usage.common.emptyValue")}
</div>
<div class="session-summary-meta">
${formatTs(usage.firstActivity)} ${formatTs(usage.lastActivity)}
</div>
<div class="session-summary-meta">${formatTs(usage.firstActivity)} ${formatTs(usage.lastActivity)}</div>
</div>
</div>
<div class="usage-insights-grid usage-insights-grid--tight">
@ -266,22 +271,22 @@ function renderSessionDetailPanel(
<div class="session-detail-header-left">
<div class="session-detail-title">
${displayLabel}
${
cursorIndicator
? html`<span class="session-detail-indicator">${cursorIndicator}</span>`
: nothing
}
${cursorIndicator
? html`<span class="session-detail-indicator">${cursorIndicator}</span>`
: nothing}
</div>
</div>
<div class="session-detail-stats">
${
usage
? html`
<span><strong>${formatTokens(headerStats.totalTokens)}</strong> ${t("usage.metrics.tokens").toLowerCase()}${cursorIndicator}</span>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing
}
${usage
? html`
<span
><strong>${formatTokens(headerStats.totalTokens)}</strong> ${t(
"usage.metrics.tokens",
).toLowerCase()}${cursorIndicator}</span
>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing}
</div>
<button
class="btn btn--sm btn--ghost"
@ -331,7 +336,12 @@ function renderSessionDetailPanel(
hasRange ? timeSeriesCursorStart : null,
hasRange ? timeSeriesCursorEnd : null,
)}
${renderContextPanel(session.contextWeight, usage, contextExpanded, onToggleContextExpanded)}
${renderContextPanel(
session.contextWeight,
usage,
contextExpanded,
onToggleContextExpanded,
)}
</div>
</div>
</div>
@ -470,17 +480,18 @@ function renderTimeSeriesCompact(
<div class="timeseries-header-row">
<div class="card-title usage-section-title">${t("usage.details.usageOverTime")}</div>
<div class="timeseries-controls">
${
hasSelection
? html`
<div class="chart-toggle small">
<button class="btn btn--sm toggle-btn active" @click=${() => onCursorRangeChange?.(null, null)}>
${t("usage.details.reset")}
</button>
</div>
`
: nothing
}
${hasSelection
? html`
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn active"
@click=${() => onCursorRangeChange?.(null, null)}
>
${t("usage.details.reset")}
</button>
</div>
`
: nothing}
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${!isCumulative ? "active" : ""}"
@ -495,49 +506,68 @@ function renderTimeSeriesCompact(
${t("usage.details.cumulative")}
</button>
</div>
${
!isCumulative
? html`
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${breakdownMode === "total" ? "active" : ""}"
@click=${() => onBreakdownChange("total")}
>
${t("usage.daily.total")}
</button>
<button
class="btn btn--sm toggle-btn ${breakdownMode === "by-type" ? "active" : ""}"
@click=${() => onBreakdownChange("by-type")}
>
${t("usage.daily.byType")}
</button>
</div>
`
: nothing
}
${!isCumulative
? html`
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${breakdownMode === "total" ? "active" : ""}"
@click=${() => onBreakdownChange("total")}
>
${t("usage.daily.total")}
</button>
<button
class="btn btn--sm toggle-btn ${breakdownMode === "by-type" ? "active" : ""}"
@click=${() => onBreakdownChange("by-type")}
>
${t("usage.daily.byType")}
</button>
</div>
`
: nothing}
</div>
</div>
<div class="timeseries-chart-wrapper">
<svg
viewBox="0 0 ${width} ${height + 18}"
class="timeseries-svg"
>
<svg viewBox="0 0 ${width} ${height + 18}" class="timeseries-svg">
<!-- Y axis -->
<line x1="${padding.left}" y1="${padding.top}" x2="${padding.left}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<line
x1="${padding.left}"
y1="${padding.top}"
x2="${padding.left}"
y2="${padding.top + chartHeight}"
stroke="var(--border)"
/>
<!-- X axis -->
<line x1="${padding.left}" y1="${padding.top + chartHeight}" x2="${width - padding.right}" y2="${padding.top + chartHeight}" stroke="var(--border)" />
<line
x1="${padding.left}"
y1="${padding.top + chartHeight}"
x2="${width - padding.right}"
y2="${padding.top + chartHeight}"
stroke="var(--border)"
/>
<!-- Y axis labels -->
<text x="${padding.left - 4}" y="${padding.top + 5}" text-anchor="end" class="ts-axis-label">${formatTokens(maxValue)}</text>
<text x="${padding.left - 4}" y="${padding.top + chartHeight}" text-anchor="end" class="ts-axis-label">0</text>
<text
x="${padding.left - 4}"
y="${padding.top + 5}"
text-anchor="end"
class="ts-axis-label"
>
${formatTokens(maxValue)}
</text>
<text
x="${padding.left - 4}"
y="${padding.top + chartHeight}"
text-anchor="end"
class="ts-axis-label"
>
0
</text>
<!-- X axis labels (first and last) -->
${
points.length > 0
? svg`
${points.length > 0
? svg`
<text x="${padding.left}" y="${padding.top + chartHeight + 10}" text-anchor="start" class="ts-axis-label">${new Date(points[0].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}</text>
<text x="${width - padding.right}" y="${padding.top + chartHeight + 10}" text-anchor="end" class="ts-axis-label">${new Date(points[points.length - 1].timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}</text>
`
: nothing
}
: nothing}
<!-- Bars -->
${points.map((p, i) => {
const val = barTotals[i];
@ -677,65 +707,92 @@ function renderTimeSeriesCompact(
};
return html`
<div class="chart-handle-zone chart-handle-left"
style="left: ${leftHandlePos};"
@mousedown=${makeDragHandler("left")}></div>
<div class="chart-handle-zone chart-handle-right"
style="left: ${rightHandlePos};"
@mousedown=${makeDragHandler("right")}></div>
<div
class="chart-handle-zone chart-handle-left"
style="left: ${leftHandlePos};"
@mousedown=${makeDragHandler("left")}
></div>
<div
class="chart-handle-zone chart-handle-right"
style="left: ${rightHandlePos};"
@mousedown=${makeDragHandler("right")}
></div>
`;
})()}
</div>
<div class="timeseries-summary">
${
hasSelection
? html`
${hasSelection
? html`
<span class="timeseries-summary__range">
${t("usage.details.turnRange", {
start: String(rangeStartIdx + 1),
end: String(rangeEndIdx),
total: String(points.length),
})}
</span> ·
${new Date(rangeStartTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}${new Date(rangeEndTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })} ·
${formatTokens(filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite)} ·
${formatCost(filteredPoints.reduce((s, p) => s + (p.cost || 0), 0))}
</span>
·
${new Date(rangeStartTs).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}${new Date(rangeEndTs).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
·
${formatTokens(
filteredOutput + filteredInput + filteredCacheRead + filteredCacheWrite,
)}
· ${formatCost(filteredPoints.reduce((s, p) => s + (p.cost || 0), 0))}
`
: html`${points.length} ${t("usage.overview.messagesAbbrev")} · ${formatTokens(cumTokens)} · ${formatCost(cumCost)}`
}
: html`${points.length} ${t("usage.overview.messagesAbbrev")} · ${formatTokens(cumTokens)}
· ${formatCost(cumCost)}`}
</div>
${
breakdownByType
? html`
<div class="timeseries-breakdown">
<div class="card-title usage-section-title">${t("usage.breakdown.tokensByType")}</div>
<div class="cost-breakdown-bar cost-breakdown-bar--compact">
<div class="cost-segment output" style="width: ${pct(filteredOutput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment input" style="width: ${pct(filteredInput, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-write" style="width: ${pct(filteredCacheWrite, totalTypeTokens).toFixed(1)}%"></div>
<div class="cost-segment cache-read" style="width: ${pct(filteredCacheRead, totalTypeTokens).toFixed(1)}%"></div>
${breakdownByType
? html`
<div class="timeseries-breakdown">
<div class="card-title usage-section-title">${t("usage.breakdown.tokensByType")}</div>
<div class="cost-breakdown-bar cost-breakdown-bar--compact">
<div
class="cost-segment output"
style="width: ${pct(filteredOutput, totalTypeTokens).toFixed(1)}%"
></div>
<div
class="cost-segment input"
style="width: ${pct(filteredInput, totalTypeTokens).toFixed(1)}%"
></div>
<div
class="cost-segment cache-write"
style="width: ${pct(filteredCacheWrite, totalTypeTokens).toFixed(1)}%"
></div>
<div
class="cost-segment cache-read"
style="width: ${pct(filteredCacheRead, totalTypeTokens).toFixed(1)}%"
></div>
</div>
<div class="cost-breakdown-legend">
<div class="legend-item" title=${t("usage.details.assistantOutputTokens")}>
<span class="legend-dot output"></span>${t("usage.breakdown.output")}
${formatTokens(filteredOutput)}
</div>
<div class="cost-breakdown-legend">
<div class="legend-item" title=${t("usage.details.assistantOutputTokens")}>
<span class="legend-dot output"></span>${t("usage.breakdown.output")} ${formatTokens(filteredOutput)}
</div>
<div class="legend-item" title=${t("usage.details.userToolInputTokens")}>
<span class="legend-dot input"></span>${t("usage.breakdown.input")} ${formatTokens(filteredInput)}
</div>
<div class="legend-item" title=${t("usage.details.tokensWrittenToCache")}>
<span class="legend-dot cache-write"></span>${t("usage.breakdown.cacheWrite")} ${formatTokens(filteredCacheWrite)}
</div>
<div class="legend-item" title=${t("usage.details.tokensReadFromCache")}>
<span class="legend-dot cache-read"></span>${t("usage.breakdown.cacheRead")} ${formatTokens(filteredCacheRead)}
</div>
<div class="legend-item" title=${t("usage.details.userToolInputTokens")}>
<span class="legend-dot input"></span>${t("usage.breakdown.input")}
${formatTokens(filteredInput)}
</div>
<div class="cost-breakdown-total">
${t("usage.breakdown.total")}: ${formatTokens(totalTypeTokens)}
<div class="legend-item" title=${t("usage.details.tokensWrittenToCache")}>
<span class="legend-dot cache-write"></span>${t("usage.breakdown.cacheWrite")}
${formatTokens(filteredCacheWrite)}
</div>
<div class="legend-item" title=${t("usage.details.tokensReadFromCache")}>
<span class="legend-dot cache-read"></span>${t("usage.breakdown.cacheRead")}
${formatTokens(filteredCacheRead)}
</div>
</div>
`
: nothing
}
<div class="cost-breakdown-total">
${t("usage.breakdown.total")}: ${formatTokens(totalTypeTokens)}
</div>
</div>
`
: nothing}
</div>
`;
}
@ -791,131 +848,151 @@ function renderContextPanel(
return html`
<div class="context-details-panel">
<div class="context-breakdown-header">
<div class="card-title usage-section-title">${t("usage.details.systemPromptBreakdown")}</div>
${
hasMore
? html`<button class="btn btn--sm" @click=${onToggleExpanded}>
${showAll ? t("usage.details.collapse") : t("usage.details.expandAll")}
</button>`
: nothing
}
<div class="card-title usage-section-title">
${t("usage.details.systemPromptBreakdown")}
</div>
${hasMore
? html`<button class="btn btn--sm" @click=${onToggleExpanded}>
${showAll ? t("usage.details.collapse") : t("usage.details.expandAll")}
</button>`
: nothing}
</div>
<p class="context-weight-desc">
${contextPct || t("usage.details.baseContextPerMessage")}
</p>
<p class="context-weight-desc">${contextPct || t("usage.details.baseContextPerMessage")}</p>
<div class="context-stacked-bar">
<div class="context-segment system" style="width: ${pct(systemTokens, totalContextTokens).toFixed(1)}%" title="${t("usage.details.system")}: ~${formatTokens(systemTokens)}"></div>
<div class="context-segment skills" style="width: ${pct(skillsTokens, totalContextTokens).toFixed(1)}%" title="${t("usage.details.skills")}: ~${formatTokens(skillsTokens)}"></div>
<div class="context-segment tools" style="width: ${pct(toolsTokens, totalContextTokens).toFixed(1)}%" title="${t("usage.details.tools")}: ~${formatTokens(toolsTokens)}"></div>
<div class="context-segment files" style="width: ${pct(filesTokens, totalContextTokens).toFixed(1)}%" title="${t("usage.details.files")}: ~${formatTokens(filesTokens)}"></div>
<div
class="context-segment system"
style="width: ${pct(systemTokens, totalContextTokens).toFixed(1)}%"
title="${t("usage.details.system")}: ~${formatTokens(systemTokens)}"
></div>
<div
class="context-segment skills"
style="width: ${pct(skillsTokens, totalContextTokens).toFixed(1)}%"
title="${t("usage.details.skills")}: ~${formatTokens(skillsTokens)}"
></div>
<div
class="context-segment tools"
style="width: ${pct(toolsTokens, totalContextTokens).toFixed(1)}%"
title="${t("usage.details.tools")}: ~${formatTokens(toolsTokens)}"
></div>
<div
class="context-segment files"
style="width: ${pct(filesTokens, totalContextTokens).toFixed(1)}%"
title="${t("usage.details.files")}: ~${formatTokens(filesTokens)}"
></div>
</div>
<div class="context-legend">
<span class="legend-item"><span class="legend-dot system"></span>${t("usage.details.systemShort")} ~${formatTokens(systemTokens)}</span>
<span class="legend-item"><span class="legend-dot skills"></span>${t("usage.details.skills")} ~${formatTokens(skillsTokens)}</span>
<span class="legend-item"><span class="legend-dot tools"></span>${t("usage.details.tools")} ~${formatTokens(toolsTokens)}</span>
<span class="legend-item"><span class="legend-dot files"></span>${t("usage.details.files")} ~${formatTokens(filesTokens)}</span>
<span class="legend-item"
><span class="legend-dot system"></span>${t("usage.details.systemShort")}
~${formatTokens(systemTokens)}</span
>
<span class="legend-item"
><span class="legend-dot skills"></span>${t("usage.details.skills")}
~${formatTokens(skillsTokens)}</span
>
<span class="legend-item"
><span class="legend-dot tools"></span>${t("usage.details.tools")}
~${formatTokens(toolsTokens)}</span
>
<span class="legend-item"
><span class="legend-dot files"></span>${t("usage.details.files")}
~${formatTokens(filesTokens)}</span
>
</div>
<div class="context-total">
${t("usage.breakdown.total")}: ~${formatTokens(totalContextTokens)}
</div>
<div class="context-total">${t("usage.breakdown.total")}: ~${formatTokens(totalContextTokens)}</div>
<div class="context-breakdown-grid">
${
skillsList.length > 0
? (() => {
const more = skillsList.length - skillsTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.skills")} (${skillsList.length})
</div>
<div class="context-breakdown-list">
${skillsTop.map(
(s) => html`
<div class="context-breakdown-item">
<span class="mono">${s.name}</span>
<span class="muted">~${formatTokens(charsToTokens(s.blockChars))}</span>
</div>
`,
)}
</div>
${
more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
${skillsList.length > 0
? (() => {
const more = skillsList.length - skillsTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.skills")} (${skillsList.length})
</div>
`;
})()
: nothing
}
${
toolsList.length > 0
? (() => {
const more = toolsList.length - toolsTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.tools")} (${toolsList.length})
</div>
<div class="context-breakdown-list">
${toolsTop.map(
(t) => html`
<div class="context-breakdown-item">
<span class="mono">${t.name}</span>
<span class="muted">~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))}</span>
</div>
`,
)}
</div>
${
more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
<div class="context-breakdown-list">
${skillsTop.map(
(s) => html`
<div class="context-breakdown-item">
<span class="mono">${s.name}</span>
<span class="muted">~${formatTokens(charsToTokens(s.blockChars))}</span>
</div>
`,
)}
</div>
`;
})()
: nothing
}
${
filesList.length > 0
? (() => {
const more = filesList.length - filesTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.files")} (${filesList.length})
</div>
<div class="context-breakdown-list">
${filesTop.map(
(f) => html`
<div class="context-breakdown-item">
<span class="mono">${f.name}</span>
<span class="muted">~${formatTokens(charsToTokens(f.injectedChars))}</span>
</div>
`,
)}
</div>
${
more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
${toolsList.length > 0
? (() => {
const more = toolsList.length - toolsTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.tools")} (${toolsList.length})
</div>
`;
})()
: nothing
}
<div class="context-breakdown-list">
${toolsTop.map(
(t) => html`
<div class="context-breakdown-item">
<span class="mono">${t.name}</span>
<span class="muted"
>~${formatTokens(charsToTokens(t.summaryChars + t.schemaChars))}</span
>
</div>
`,
)}
</div>
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
${filesList.length > 0
? (() => {
const more = filesList.length - filesTop.length;
return html`
<div class="context-breakdown-card">
<div class="context-breakdown-title">
${t("usage.details.files")} (${filesList.length})
</div>
<div class="context-breakdown-list">
${filesTop.map(
(f) => html`
<div class="context-breakdown-item">
<span class="mono">${f.name}</span>
<span class="muted"
>~${formatTokens(charsToTokens(f.injectedChars))}</span
>
</div>
`,
)}
</div>
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing}
</div>
`;
})()
: nothing}
</div>
</div>
`;
@ -1035,10 +1112,18 @@ function renderSessionLogsCompact(
),
)}
>
<option value="user" ?selected=${roleSelected.has("user")}>${t("usage.overview.user")}</option>
<option value="assistant" ?selected=${roleSelected.has("assistant")}>${t("usage.overview.assistant")}</option>
<option value="tool" ?selected=${roleSelected.has("tool")}>${t("usage.details.tool")}</option>
<option value="toolResult" ?selected=${roleSelected.has("toolResult")}>${t("usage.details.toolResult")}</option>
<option value="user" ?selected=${roleSelected.has("user")}>
${t("usage.overview.user")}
</option>
<option value="assistant" ?selected=${roleSelected.has("assistant")}>
${t("usage.overview.assistant")}
</option>
<option value="tool" ?selected=${roleSelected.has("tool")}>
${t("usage.details.tool")}
</option>
<option value="toolResult" ?selected=${roleSelected.has("toolResult")}>
${t("usage.details.toolResult")}
</option>
</select>
<select
multiple
@ -1072,9 +1157,7 @@ function renderSessionLogsCompact(
.value=${filters.query}
@input=${(event: Event) => onFilterQueryChange((event.target as HTMLInputElement).value)}
/>
<button class="btn btn--sm" @click=${onFilterClear}>
${t("usage.filters.clear")}
</button>
<button class="btn btn--sm" @click=${onFilterClear}>${t("usage.filters.clear")}</button>
</div>
<div class="session-logs-list">
${filteredEntries.map((entry) => {
@ -1087,15 +1170,14 @@ function renderSessionLogsCompact(
? t("usage.overview.assistant")
: t("usage.details.tool");
return html`
<div class="session-log-entry ${roleClass}">
<div class="session-log-meta">
<span class="session-log-role">${roleLabel}</span>
<span>${new Date(log.timestamp).toLocaleString()}</span>
${log.tokens ? html`<span>${formatTokens(log.tokens)}</span>` : nothing}
</div>
<div class="session-log-content">${cleanContent}</div>
${
toolInfo.tools.length > 0
<div class="session-log-entry ${roleClass}">
<div class="session-log-meta">
<span class="session-log-role">${roleLabel}</span>
<span>${new Date(log.timestamp).toLocaleString()}</span>
${log.tokens ? html`<span>${formatTokens(log.tokens)}</span>` : nothing}
</div>
<div class="session-log-content">${cleanContent}</div>
${toolInfo.tools.length > 0
? html`
<details class="session-log-tools" ?open=${expandedAll}>
<summary>${toolInfo.summary}</summary>
@ -1108,20 +1190,17 @@ function renderSessionLogsCompact(
</div>
</details>
`
: nothing
}
</div>
`;
: nothing}
</div>
`;
})}
${
filteredEntries.length === 0
? html`
<div class="usage-empty-block usage-empty-block--compact">
${t("usage.details.noMessagesMatch")}
</div>
`
: nothing
}
${filteredEntries.length === 0
? html`
<div class="usage-empty-block usage-empty-block--compact">
${t("usage.details.noMessagesMatch")}
</div>
`
: nothing}
</div>
</div>
`;

View File

@ -93,9 +93,8 @@ function renderFilterChips(
return html`
<div class="active-filters">
${
selectedDays.length > 0
? html`
${selectedDays.length > 0
? html`
<div class="filter-chip">
<span class="filter-chip-label">${t("usage.filters.days")}: ${daysLabel}</span>
<button
@ -108,11 +107,9 @@ function renderFilterChips(
</button>
</div>
`
: nothing
}
${
selectedHours.length > 0
? html`
: nothing}
${selectedHours.length > 0
? html`
<div class="filter-chip">
<span class="filter-chip-label">${t("usage.filters.hours")}: ${hoursLabel}</span>
<button
@ -125,11 +122,9 @@ function renderFilterChips(
</button>
</div>
`
: nothing
}
${
selectedSessions.length > 0
? html`
: nothing}
${selectedSessions.length > 0
? html`
<div class="filter-chip" title="${sessionsFullName}">
<span class="filter-chip-label">${t("usage.filters.session")}: ${sessionsLabel}</span>
<button
@ -142,17 +137,14 @@ function renderFilterChips(
</button>
</div>
`
: nothing
}
${
(selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0
? html`
: nothing}
${(selectedDays.length > 0 || selectedHours.length > 0) && selectedSessions.length > 0
? html`
<button class="btn btn--sm" @click=${onClearFilters}>
${t("usage.filters.clearAll")}
</button>
`
: nothing
}
: nothing}
</div>
`;
}
@ -267,41 +259,35 @@ function renderDailyChartCompact(
class="daily-bar-wrapper ${isSelected ? "selected" : ""}"
@click=${(e: MouseEvent) => onSelectDay(d.date, e.shiftKey)}
>
${
dailyChartMode === "by-type"
? html`
<div
class="daily-bar daily-bar--stacked"
style="height: ${heightPx.toFixed(0)}px;"
>
${(() => {
const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
return segments.map(
(seg) => html`
<div
class="cost-segment ${seg.class}"
style="height: ${(seg.value / total) * 100}%"
></div>
`,
);
})()}
</div>
`
: html`
<div class="daily-bar" style="height: ${heightPx.toFixed(0)}px"></div>
`
}
${dailyChartMode === "by-type"
? html`
<div
class="daily-bar daily-bar--stacked"
style="height: ${heightPx.toFixed(0)}px;"
>
${(() => {
const total = segments.reduce((sum, seg) => sum + seg.value, 0) || 1;
return segments.map(
(seg) => html`
<div
class="cost-segment ${seg.class}"
style="height: ${(seg.value / total) * 100}%"
></div>
`,
);
})()}
</div>
`
: html` <div class="daily-bar" style="height: ${heightPx.toFixed(0)}px"></div> `}
${showTotals ? html`<div class="daily-bar-total">${totalLabel}</div>` : nothing}
<div class="${labelClass}">${shortLabel}</div>
<div class="daily-bar-tooltip">
<strong>${formatFullDate(d.date)}</strong><br />
${formatTokens(d.totalTokens)} ${t("usage.metrics.tokens").toLowerCase()}<br />
${formatCost(d.totalCost)}
${
breakdownLines.length
? html`${breakdownLines.map((line) => html`<div>${line}</div>`)}`
: nothing
}
${breakdownLines.length
? html`${breakdownLines.map((line) => html`<div>${line}</div>`)}`
: nothing}
</div>
</div>
`;
@ -329,23 +315,64 @@ function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost"
${isTokenMode ? t("usage.breakdown.tokensByType") : t("usage.breakdown.costByType")}
</div>
<div class="cost-breakdown-bar">
<div class="cost-segment output" style="width: ${(isTokenMode ? tokenPcts.output : breakdown.output.pct).toFixed(1)}%"
title="${t("usage.breakdown.output")}: ${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)}"></div>
<div class="cost-segment input" style="width: ${(isTokenMode ? tokenPcts.input : breakdown.input.pct).toFixed(1)}%"
title="${t("usage.breakdown.input")}: ${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)}"></div>
<div class="cost-segment cache-write" style="width: ${(isTokenMode ? tokenPcts.cacheWrite : breakdown.cacheWrite.pct).toFixed(1)}%"
title="${t("usage.breakdown.cacheWrite")}: ${isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)}"></div>
<div class="cost-segment cache-read" style="width: ${(isTokenMode ? tokenPcts.cacheRead : breakdown.cacheRead.pct).toFixed(1)}%"
title="${t("usage.breakdown.cacheRead")}: ${isTokenMode ? formatTokens(totals.cacheRead) : formatCost(breakdown.cacheRead.cost)}"></div>
<div
class="cost-segment output"
style="width: ${(isTokenMode ? tokenPcts.output : breakdown.output.pct).toFixed(1)}%"
title="${t("usage.breakdown.output")}: ${isTokenMode
? formatTokens(totals.output)
: formatCost(breakdown.output.cost)}"
></div>
<div
class="cost-segment input"
style="width: ${(isTokenMode ? tokenPcts.input : breakdown.input.pct).toFixed(1)}%"
title="${t("usage.breakdown.input")}: ${isTokenMode
? formatTokens(totals.input)
: formatCost(breakdown.input.cost)}"
></div>
<div
class="cost-segment cache-write"
style="width: ${(isTokenMode ? tokenPcts.cacheWrite : breakdown.cacheWrite.pct).toFixed(
1,
)}%"
title="${t("usage.breakdown.cacheWrite")}: ${isTokenMode
? formatTokens(totals.cacheWrite)
: formatCost(breakdown.cacheWrite.cost)}"
></div>
<div
class="cost-segment cache-read"
style="width: ${(isTokenMode ? tokenPcts.cacheRead : breakdown.cacheRead.pct).toFixed(
1,
)}%"
title="${t("usage.breakdown.cacheRead")}: ${isTokenMode
? formatTokens(totals.cacheRead)
: formatCost(breakdown.cacheRead.cost)}"
></div>
</div>
<div class="cost-breakdown-legend">
<span class="legend-item"><span class="legend-dot output"></span>${t("usage.breakdown.output")} ${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)}</span>
<span class="legend-item"><span class="legend-dot input"></span>${t("usage.breakdown.input")} ${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)}</span>
<span class="legend-item"><span class="legend-dot cache-write"></span>${t("usage.breakdown.cacheWrite")} ${isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)}</span>
<span class="legend-item"><span class="legend-dot cache-read"></span>${t("usage.breakdown.cacheRead")} ${isTokenMode ? formatTokens(totals.cacheRead) : formatCost(breakdown.cacheRead.cost)}</span>
<span class="legend-item"
><span class="legend-dot output"></span>${t("usage.breakdown.output")}
${isTokenMode ? formatTokens(totals.output) : formatCost(breakdown.output.cost)}</span
>
<span class="legend-item"
><span class="legend-dot input"></span>${t("usage.breakdown.input")}
${isTokenMode ? formatTokens(totals.input) : formatCost(breakdown.input.cost)}</span
>
<span class="legend-item"
><span class="legend-dot cache-write"></span>${t("usage.breakdown.cacheWrite")}
${isTokenMode
? formatTokens(totals.cacheWrite)
: formatCost(breakdown.cacheWrite.cost)}</span
>
<span class="legend-item"
><span class="legend-dot cache-read"></span>${t("usage.breakdown.cacheRead")}
${isTokenMode
? formatTokens(totals.cacheRead)
: formatCost(breakdown.cacheRead.cost)}</span
>
</div>
<div class="cost-breakdown-total">
${t("usage.breakdown.total")}: ${isTokenMode ? formatTokens(totals.totalTokens) : formatCost(totals.totalCost)}
${t("usage.breakdown.total")}:
${isTokenMode ? formatTokens(totals.totalTokens) : formatCost(totals.totalCost)}
</div>
</div>
`;
@ -359,25 +386,23 @@ function renderInsightList(
return html`
<div class="usage-insight-card">
<div class="usage-insight-title">${title}</div>
${
items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class="usage-list">
${items.map(
(item) => html`
<div class="usage-list-item">
<span>${item.label}</span>
<span class="usage-list-value">
<span>${item.value}</span>
${item.sub ? html`<span class="usage-list-sub">${item.sub}</span>` : nothing}
</span>
</div>
`,
)}
</div>
`
}
${items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class="usage-list">
${items.map(
(item) => html`
<div class="usage-list-item">
<span>${item.label}</span>
<span class="usage-list-value">
<span>${item.value}</span>
${item.sub ? html`<span class="usage-list-sub">${item.sub}</span>` : nothing}
</span>
</div>
`,
)}
</div>
`}
</div>
`;
}
@ -396,23 +421,21 @@ function renderPeakErrorList(
return html`
<div class=${cardClass}>
<div class="usage-insight-title">${title}</div>
${
items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class=${listClass}>
${items.map(
(item) => html`
<div class="usage-error-row">
<div class="usage-error-date">${item.label}</div>
<div class="usage-error-rate">${item.value}</div>
${item.sub ? html`<div class="usage-error-sub">${item.sub}</div>` : nothing}
</div>
`,
)}
</div>
`
}
${items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class=${listClass}>
${items.map(
(item) => html`
<div class="usage-error-row">
<div class="usage-error-date">${item.label}</div>
<div class="usage-error-rate">${item.value}</div>
${item.sub ? html`<div class="usage-error-sub">${item.sub}</div>` : nothing}
</div>
`,
)}
</div>
`}
</div>
`;
}
@ -777,7 +800,9 @@ function renderSessionsCard(
>
<div class="session-bar-label">
<div class="session-bar-title">${displayLabel}</div>
${meta.length > 0 ? html`<div class="session-bar-meta">${meta.join(" · ")}</div>` : nothing}
${meta.length > 0
? html`<div class="session-bar-meta">${meta.join(" · ")}</div>`
: nothing}
</div>
<div class="session-bar-actions">
<button
@ -790,7 +815,9 @@ function renderSessionsCard(
>
${t("usage.sessions.copy")}
</button>
<div class="session-bar-value">${isTokenMode ? formatTokens(value) : formatCost(value)}</div>
<div class="session-bar-value">
${isTokenMode ? formatTokens(value) : formatCost(value)}
</div>
</div>
</div>
`;
@ -810,17 +837,16 @@ function renderSessionsCard(
<div class="card-title">${t("usage.sessions.title")}</div>
<div class="sessions-card-count">
${t("usage.sessions.shown", { count: String(sessions.length) })}
${
totalSessions !== sessions.length
? ` · ${t("usage.sessions.total", { count: String(totalSessions) })}`
: ""
}
${totalSessions !== sessions.length
? ` · ${t("usage.sessions.total", { count: String(totalSessions) })}`
: ""}
</div>
</div>
<div class="sessions-card-meta">
<div class="sessions-card-stats">
<span>
${isTokenMode ? formatTokens(avgValue) : formatCost(avgValue)} ${t("usage.sessions.avg")}
${isTokenMode ? formatTokens(avgValue) : formatCost(avgValue)}
${t("usage.sessions.avg")}
</span>
<span>${totalErrors} ${t("usage.overview.errors").toLowerCase()}</span>
</div>
@ -841,82 +867,79 @@ function renderSessionsCard(
<label class="sessions-sort">
<span>${t("usage.sessions.sort")}</span>
<select
@change=${(e: Event) => onSessionSortChange((e.target as HTMLSelectElement).value as typeof sessionSort)}
@change=${(e: Event) =>
onSessionSortChange((e.target as HTMLSelectElement).value as typeof sessionSort)}
>
<option value="cost" ?selected=${sessionSort === "cost"}>${t("usage.metrics.cost")}</option>
<option value="errors" ?selected=${sessionSort === "errors"}>${t("usage.overview.errors")}</option>
<option value="messages" ?selected=${sessionSort === "messages"}>${t("usage.overview.messages")}</option>
<option value="recent" ?selected=${sessionSort === "recent"}>${t("usage.sessions.recentShort")}</option>
<option value="tokens" ?selected=${sessionSort === "tokens"}>${t("usage.metrics.tokens")}</option>
<option value="cost" ?selected=${sessionSort === "cost"}>
${t("usage.metrics.cost")}
</option>
<option value="errors" ?selected=${sessionSort === "errors"}>
${t("usage.overview.errors")}
</option>
<option value="messages" ?selected=${sessionSort === "messages"}>
${t("usage.overview.messages")}
</option>
<option value="recent" ?selected=${sessionSort === "recent"}>
${t("usage.sessions.recentShort")}
</option>
<option value="tokens" ?selected=${sessionSort === "tokens"}>
${t("usage.metrics.tokens")}
</option>
</select>
</label>
<button
class="btn btn--sm"
@click=${() => onSessionSortDirChange(sessionSortDir === "desc" ? "asc" : "desc")}
title=${
sessionSortDir === "desc"
? t("usage.sessions.descending")
: t("usage.sessions.ascending")
}
title=${sessionSortDir === "desc"
? t("usage.sessions.descending")
: t("usage.sessions.ascending")}
>
${sessionSortDir === "desc" ? "↓" : "↑"}
</button>
${
selectedCount > 0
? html`
<button class="btn btn--sm" @click=${onClearSessions}>
${t("usage.sessions.clearSelection")}
</button>
`
: nothing
}
</div>
${
sessionsTab === "recent"
? recentEntries.length === 0
? html`
<div class="usage-empty-block">${t("usage.sessions.noRecent")}</div>
`
: html`
<div class="session-bars session-bars--recent">
${recentEntries.map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))}
</div>
`
: sessions.length === 0
? html`
<div class="usage-empty-block">${t("usage.sessions.noneInRange")}</div>
`
: html`
<div class="session-bars">
${sortedWithDir
.slice(0, 50)
.map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))}
${
sessions.length > 50
? html`
<div class="usage-more-sessions">
${t("usage.sessions.more", { count: String(sessions.length - 50) })}
</div>
`
: nothing
}
</div>
`
}
${
selectedCount > 1
${selectedCount > 0
? html`
<div class="sessions-selected-group">
<div class="sessions-card-count">
${t("usage.sessions.selected", { count: String(selectedCount) })}
</div>
<div class="session-bars session-bars--selected">
${selectedEntries.map((s) => renderSessionBarRow(s, true))}
</div>
<button class="btn btn--sm" @click=${onClearSessions}>
${t("usage.sessions.clearSelection")}
</button>
`
: nothing}
</div>
${sessionsTab === "recent"
? recentEntries.length === 0
? html` <div class="usage-empty-block">${t("usage.sessions.noRecent")}</div> `
: html`
<div class="session-bars session-bars--recent">
${recentEntries.map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))}
</div>
`
: nothing
}
: sessions.length === 0
? html` <div class="usage-empty-block">${t("usage.sessions.noneInRange")}</div> `
: html`
<div class="session-bars">
${sortedWithDir
.slice(0, 50)
.map((s) => renderSessionBarRow(s, selectedSet.has(s.key)))}
${sessions.length > 50
? html`
<div class="usage-more-sessions">
${t("usage.sessions.more", { count: String(sessions.length - 50) })}
</div>
`
: nothing}
</div>
`}
${selectedCount > 1
? html`
<div class="sessions-selected-group">
<div class="sessions-card-count">
${t("usage.sessions.selected", { count: String(selectedCount) })}
</div>
<div class="session-bars session-bars--selected">
${selectedEntries.map((s) => renderSessionBarRow(s, true))}
</div>
</div>
`
: nothing}
</div>
`;
}

View File

@ -128,9 +128,7 @@ function renderUsageEmptyState(onRefresh: () => void) {
<span class="usage-empty-state__feature">${t("usage.empty.featureTimeline")}</span>
</div>
<div class="usage-empty-state__actions">
<button class="btn primary" @click=${onRefresh}>
${t("common.refresh")}
</button>
<button class="btn primary" @click=${onRefresh}>${t("common.refresh")}</button>
</div>
</section>
`;
@ -378,13 +376,9 @@ export function renderUsage(props: UsageProps) {
>
<summary>
<span>${label}</span>
${
selectedCount > 0
? html`<span class="usage-filter-badge">${selectedCount}</span>`
: html`
<span class="usage-filter-badge">${t("usage.filters.all")}</span>
`
}
${selectedCount > 0
? html`<span class="usage-filter-badge">${selectedCount}</span>`
: html` <span class="usage-filter-badge">${t("usage.filters.all")}</span> `}
</summary>
<div class="usage-filter-popover">
<div class="usage-filter-actions">
@ -453,32 +447,32 @@ export function renderUsage(props: UsageProps) {
<div class="usage-header-row">
<div class="usage-header-title">
<div class="card-title usage-section-title">${t("usage.filters.title")}</div>
${data.loading ? html`<span class="usage-refresh-indicator">${t("usage.loading.badge")}</span>` : nothing}
${isEmpty ? html`<span class="usage-query-hint">${t("usage.empty.hint")}</span>` : nothing}
${data.loading
? html`<span class="usage-refresh-indicator">${t("usage.loading.badge")}</span>`
: nothing}
${isEmpty
? html`<span class="usage-query-hint">${t("usage.empty.hint")}</span>`
: nothing}
</div>
<div class="usage-header-metrics">
${
displayTotals
? html`
<span class="usage-metric-badge">
<strong>${formatTokens(displayTotals.totalTokens)}</strong>
${t("usage.metrics.tokens")}
</span>
<span class="usage-metric-badge">
<strong>${formatCost(displayTotals.totalCost)}</strong>
${t("usage.metrics.cost")}
</span>
<span class="usage-metric-badge">
<strong>${displaySessionCount}</strong>
${
displaySessionCount === 1
? t("usage.metrics.session")
: t("usage.metrics.sessions")
}
</span>
`
: nothing
}
${displayTotals
? html`
<span class="usage-metric-badge">
<strong>${formatTokens(displayTotals.totalTokens)}</strong>
${t("usage.metrics.tokens")}
</span>
<span class="usage-metric-badge">
<strong>${formatCost(displayTotals.totalCost)}</strong>
${t("usage.metrics.cost")}
</span>
<span class="usage-metric-badge">
<strong>${displaySessionCount}</strong>
${displaySessionCount === 1
? t("usage.metrics.session")
: t("usage.metrics.sessions")}
</span>
`
: nothing}
<button
class="btn btn--sm usage-pin-btn ${display.headerPinned ? "active" : ""}"
title=${display.headerPinned ? t("usage.filters.unpin") : t("usage.filters.pin")}
@ -660,27 +654,20 @@ export function renderUsage(props: UsageProps) {
>
${t("usage.query.apply")}
</button>
${
hasDraftQuery || hasQuery
? html`
<button
class="btn btn--sm"
@click=${filterActions.onClearQuery}
>
${t("usage.filters.clear")}
</button>
`
: nothing
}
${hasDraftQuery || hasQuery
? html`
<button class="btn btn--sm" @click=${filterActions.onClearQuery}>
${t("usage.filters.clear")}
</button>
`
: nothing}
<span class="usage-query-hint">
${
hasQuery
? t("usage.query.matching", {
shown: String(filteredSessions.length),
total: String(totalSessions),
})
: t("usage.query.inRange", { total: String(totalSessions) })
}
${hasQuery
? t("usage.query.matching", {
shown: String(filteredSessions.length),
total: String(totalSessions),
})
: t("usage.query.inRange", { total: String(totalSessions) })}
</span>
</div>
</div>
@ -692,169 +679,153 @@ export function renderUsage(props: UsageProps) {
${renderFilterSelect("tool", t("usage.filters.tool"), toolOptions)}
<span class="usage-query-hint">${t("usage.query.tip")}</span>
</div>
${
queryTerms.length > 0
? html`
<div class="usage-query-chips">
${queryTerms.map((term) => {
const label = term.raw;
return html`
<span class="usage-query-chip">
${label}
<button
title=${t("usage.filters.remove")}
@click=${() =>
filterActions.onQueryDraftChange(
removeQueryToken(filters.queryDraft, label),
)}
>
×
</button>
</span>
`;
})}
</div>
`
: nothing
}
${
querySuggestions.length > 0
? html`
<div class="usage-query-suggestions">
${querySuggestions.map(
(suggestion) => html`
${queryTerms.length > 0
? html`
<div class="usage-query-chips">
${queryTerms.map((term) => {
const label = term.raw;
return html`
<span class="usage-query-chip">
${label}
<button
class="usage-query-suggestion"
title=${t("usage.filters.remove")}
@click=${() =>
filterActions.onQueryDraftChange(
applySuggestionToQuery(filters.queryDraft, suggestion.value),
removeQueryToken(filters.queryDraft, label),
)}
>
${suggestion.label}
×
</button>
`,
)}
</div>
`
: nothing
}
${
queryWarnings.length > 0
? html`
<div class="callout warning usage-callout usage-callout--tight">
${queryWarnings.join(" · ")}
</div>
`
: nothing
}
</div>
${data.error ? html`<div class="callout danger usage-callout">${data.error}</div>` : nothing}
${
data.sessionsLimitReached
? html`
<div class="callout warning usage-callout">
${t("usage.sessions.limitReached")}
</span>
`;
})}
</div>
`
: nothing
}
</section>
${
isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
${renderUsageInsights(
displayTotals,
activeAggregates,
insightStats,
hasMissingCost,
buildPeakErrorHours(aggregateSessions, filters.timeZone),
displaySessionCount,
totalSessions,
)}
${renderUsageMosaic(
aggregateSessions,
filters.timeZone,
filters.selectedHours,
filterActions.onSelectHour,
)}
<div class="usage-grid">
<div class="usage-grid-column">
<div class="card usage-left-card">
${renderDailyChartCompact(
filteredDaily,
filters.selectedDays,
display.chartMode,
display.dailyChartMode,
displayActions.onDailyChartModeChange,
filterActions.onSelectDay,
)}
${
displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing
}
</div>
${renderSessionsCard(
filteredSessions,
filters.selectedSessions,
filters.selectedDays,
isTokenMode,
display.sessionSort,
display.sessionSortDir,
display.recentSessions,
display.sessionsTab,
detailActions.onSelectSession,
displayActions.onSessionSortChange,
displayActions.onSessionSortDirChange,
displayActions.onSessionsTabChange,
display.visibleColumns,
totalSessions,
filterActions.onClearSessions,
: nothing}
${querySuggestions.length > 0
? html`
<div class="usage-query-suggestions">
${querySuggestions.map(
(suggestion) => html`
<button
class="usage-query-suggestion"
@click=${() =>
filterActions.onQueryDraftChange(
applySuggestionToQuery(filters.queryDraft, suggestion.value),
)}
>
${suggestion.label}
</button>
`,
)}
</div>
${
primarySelectedEntry
? html`<div class="usage-grid-column">
${renderSessionDetailPanel(
primarySelectedEntry,
detail.timeSeries,
detail.timeSeriesLoading,
detail.timeSeriesMode,
detailActions.onTimeSeriesModeChange,
detail.timeSeriesBreakdownMode,
detailActions.onTimeSeriesBreakdownChange,
detail.timeSeriesCursorStart,
detail.timeSeriesCursorEnd,
detailActions.onTimeSeriesCursorRangeChange,
filters.startDate,
filters.endDate,
filters.selectedDays,
detail.sessionLogs,
detail.sessionLogsLoading,
detail.sessionLogsExpanded,
detailActions.onToggleSessionLogsExpanded,
detail.logFilters,
detailActions.onLogFilterRolesChange,
detailActions.onLogFilterToolsChange,
detailActions.onLogFilterHasToolsChange,
detailActions.onLogFilterQueryChange,
detailActions.onLogFilterClear,
display.contextExpanded,
detailActions.onToggleContextExpanded,
filterActions.onClearSessions,
)}
</div>`
: nothing
}
</div>
`
: nothing}
${queryWarnings.length > 0
? html`
<div class="callout warning usage-callout usage-callout--tight">
${queryWarnings.join(" · ")}
</div>
`
: nothing}
</div>
${data.error
? html`<div class="callout danger usage-callout">${data.error}</div>`
: nothing}
${data.sessionsLimitReached
? html`
<div class="callout warning usage-callout">${t("usage.sessions.limitReached")}</div>
`
}
: nothing}
</section>
${isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
${renderUsageInsights(
displayTotals,
activeAggregates,
insightStats,
hasMissingCost,
buildPeakErrorHours(aggregateSessions, filters.timeZone),
displaySessionCount,
totalSessions,
)}
${renderUsageMosaic(
aggregateSessions,
filters.timeZone,
filters.selectedHours,
filterActions.onSelectHour,
)}
<div class="usage-grid">
<div class="usage-grid-column">
<div class="card usage-left-card">
${renderDailyChartCompact(
filteredDaily,
filters.selectedDays,
display.chartMode,
display.dailyChartMode,
displayActions.onDailyChartModeChange,
filterActions.onSelectDay,
)}
${displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing}
</div>
${renderSessionsCard(
filteredSessions,
filters.selectedSessions,
filters.selectedDays,
isTokenMode,
display.sessionSort,
display.sessionSortDir,
display.recentSessions,
display.sessionsTab,
detailActions.onSelectSession,
displayActions.onSessionSortChange,
displayActions.onSessionSortDirChange,
displayActions.onSessionsTabChange,
display.visibleColumns,
totalSessions,
filterActions.onClearSessions,
)}
</div>
${primarySelectedEntry
? html`<div class="usage-grid-column">
${renderSessionDetailPanel(
primarySelectedEntry,
detail.timeSeries,
detail.timeSeriesLoading,
detail.timeSeriesMode,
detailActions.onTimeSeriesModeChange,
detail.timeSeriesBreakdownMode,
detailActions.onTimeSeriesBreakdownChange,
detail.timeSeriesCursorStart,
detail.timeSeriesCursorEnd,
detailActions.onTimeSeriesCursorRangeChange,
filters.startDate,
filters.endDate,
filters.selectedDays,
detail.sessionLogs,
detail.sessionLogsLoading,
detail.sessionLogsExpanded,
detailActions.onToggleSessionLogsExpanded,
detail.logFilters,
detailActions.onLogFilterRolesChange,
detailActions.onLogFilterToolsChange,
detailActions.onLogFilterHasToolsChange,
detailActions.onLogFilterQueryChange,
detailActions.onLogFilterClear,
display.contextExpanded,
detailActions.onToggleContextExpanded,
filterActions.onClearSessions,
)}
</div>`
: nothing}
</div>
`}
</div>
`;
}

View File

@ -24,6 +24,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"],
/* Linting */
"strict": true,

View File

@ -21,6 +21,7 @@ const isWindowsEnv = (env: EnvMap, platform: NodeJS.Platform): boolean => {
type VitestExperimentalConfig = {
experimental?: {
fsModuleCache?: true;
fsModuleCachePath?: string;
importDurations?: { print: true };
printImportBreakdown?: true;
};
@ -32,6 +33,7 @@ export function loadVitestExperimentalConfig(
): VitestExperimentalConfig {
const experimental: {
fsModuleCache?: true;
fsModuleCachePath?: string;
importDurations?: { print: true };
printImportBreakdown?: true;
} = {};
@ -43,6 +45,9 @@ export function loadVitestExperimentalConfig(
if (windowsEnv && isEnabled(env.OPENCLAW_VITEST_FS_MODULE_CACHE)) {
experimental.fsModuleCache = true;
}
if (experimental.fsModuleCache && env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH?.trim()) {
experimental.fsModuleCachePath = env.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH.trim();
}
if (isEnabled(env.OPENCLAW_VITEST_IMPORT_DURATIONS)) {
experimental.importDurations = { print: true };
}