fix: align skill and compaction API usage

This commit is contained in:
Peter Steinberger
2026-03-27 03:25:24 +00:00
parent 0235cca58a
commit be6b841334
54 changed files with 2785 additions and 3396 deletions

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

@@ -257,6 +257,7 @@ async function summarizeChunks(params: {
model,
params.reserveTokens,
params.apiKey,
params.headers,
params.signal,
effectiveInstructions,
summary,
@@ -283,6 +284,7 @@ export async function summarizeWithFallback(params: {
messages: AgentMessage[];
model: NonNullable<ExtensionContext["model"]>;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
reserveTokens: number;
maxChunkTokens: number;
@@ -352,6 +354,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

@@ -131,13 +131,13 @@ const createCompactionEvent = (params: { messageText: string; tokensBefore: numb
const createCompactionContext = (params: {
sessionManager: ExtensionContext["sessionManager"];
getApiKeyMock: ReturnType<typeof vi.fn>;
getApiKeyAndHeadersMock: ReturnType<typeof vi.fn>;
}) =>
({
model: undefined,
sessionManager: params.sessionManager,
modelRegistry: {
getApiKey: params.getApiKeyMock,
getApiKeyAndHeaders: params.getApiKeyAndHeadersMock,
},
}) as unknown as Partial<ExtensionContext>;
@@ -147,10 +147,14 @@ async function runCompactionScenario(params: {
apiKey: string | null;
}) {
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue(params.apiKey);
const getApiKeyAndHeadersMock = vi
.fn()
.mockResolvedValue(
params.apiKey ? { ok: true, apiKey: params.apiKey } : { ok: false, error: "missing auth" },
);
const mockContext = createCompactionContext({
sessionManager: params.sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const result = (await compactionHandler(params.event, mockContext)) as {
cancel?: boolean;
@@ -160,7 +164,7 @@ async function runCompactionScenario(params: {
tokensBefore: number;
};
};
return { result, getApiKeyMock };
return { result, getApiKeyAndHeadersMock };
}
function expectCompactionResult(result: {
@@ -1222,10 +1226,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const messagesToSummarize: AgentMessage[] = Array.from({ length: 4 }, (_unused, index) => ({
role: "user",
@@ -1278,10 +1282,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const event = {
preparation: {
@@ -1343,10 +1347,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const event = {
preparation: {
@@ -1446,10 +1450,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const event = {
preparation: {
@@ -1509,10 +1513,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const event = {
preparation: {
@@ -1572,10 +1576,10 @@ describe("compaction-safeguard recent-turn preservation", () => {
});
const compactionHandler = createCompactionHandler();
const getApiKeyMock = vi.fn().mockResolvedValue("test-key");
const getApiKeyAndHeadersMock = vi.fn().mockResolvedValue({ ok: true, apiKey: "test-key" });
const mockContext = createCompactionContext({
sessionManager,
getApiKeyMock,
getApiKeyAndHeadersMock,
});
const event = {
preparation: {
@@ -1634,7 +1638,7 @@ describe("compaction-safeguard extension model fallback", () => {
messageText: "test message",
tokensBefore: 1000,
});
const { result, getApiKeyMock } = await runCompactionScenario({
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
@@ -1643,8 +1647,9 @@ describe("compaction-safeguard extension model fallback", () => {
expect(result).toEqual({ cancel: true });
// KEY ASSERTION: Prove the fallback path was exercised
// The handler should have called getApiKey with runtime.model (via ctx.model ?? runtime?.model)
expect(getApiKeyMock).toHaveBeenCalledWith(model);
// The handler should have resolved request auth with runtime.model
// (via ctx.model ?? runtime?.model).
expect(getApiKeyAndHeadersMock).toHaveBeenCalledWith(model);
// Verify runtime.model is still available (for completeness)
const retrieved = getCompactionSafeguardRuntime(sessionManager);
@@ -1660,7 +1665,7 @@ describe("compaction-safeguard extension model fallback", () => {
messageText: "test",
tokensBefore: 500,
});
const { result, getApiKeyMock } = await runCompactionScenario({
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
@@ -1668,8 +1673,8 @@ describe("compaction-safeguard extension model fallback", () => {
expect(result).toEqual({ cancel: true });
// Verify early return: getApiKey should NOT have been called when both models are missing
expect(getApiKeyMock).not.toHaveBeenCalled();
// Verify early return: request auth should NOT have been resolved when both models are missing.
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
});
});
@@ -1690,7 +1695,7 @@ describe("compaction-safeguard double-compaction guard", () => {
customInstructions: "",
signal: new AbortController().signal,
};
const { result, getApiKeyMock } = await runCompactionScenario({
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: "sk-test", // pragma: allowlist secret
@@ -1704,7 +1709,7 @@ describe("compaction-safeguard double-compaction guard", () => {
expect(compaction.summary).toContain("## Open TODOs");
expect(compaction.firstKeptEntryId).toBe("entry-1");
expect(compaction.tokensBefore).toBe(1500);
expect(getApiKeyMock).not.toHaveBeenCalled();
expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled();
});
it("returns compaction result with structured fallback summary sections", async () => {
@@ -1815,13 +1820,13 @@ describe("compaction-safeguard double-compaction guard", () => {
messageText: "real message",
tokensBefore: 1500,
});
const { result, getApiKeyMock } = await runCompactionScenario({
const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({
sessionManager,
event: mockEvent,
apiKey: null,
});
expect(result).toEqual({ cancel: true });
expect(getApiKeyMock).toHaveBeenCalled();
expect(getApiKeyAndHeadersMock).toHaveBeenCalled();
});
it("treats tool results as real conversation only when linked to a meaningful user ask", async () => {

View File

@@ -614,11 +614,13 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
return { cancel: true };
}
const apiKey = (await ctx.modelRegistry.getApiKey(model)) ?? "";
const headers =
const fallbackHeaders =
model.headers && typeof model.headers === "object" && !Array.isArray(model.headers)
? model.headers
: undefined;
const requestAuth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
const apiKey = requestAuth.ok ? (requestAuth.apiKey ?? "") : "";
const headers = requestAuth.ok ? (requestAuth.headers ?? fallbackHeaders) : fallbackHeaders;
if (!apiKey && !headers) {
log.warn(
"Compaction safeguard: no request auth available; cancelling compaction to preserve history.",
@@ -692,6 +694,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,
@@ -763,6 +766,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: messagesToSummarize,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,
@@ -779,6 +783,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: turnPrefixMessages,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,

View File

@@ -60,7 +60,13 @@ function buildEntry(name: string): SkillEntry {
description: `${name} test skill`,
filePath: path.join(skillDir, "SKILL.md"),
baseDir: skillDir,
source: "openclaw-workspace",
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,10 @@ 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)) {
const skillSource = entry.skill.sourceInfo.source;
if (!trustedInstallSources.has(skillSource)) {
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 "${skillSource}". Verify the install recipe is trusted.`,
);
}
if (!spec) {

View File

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

View File

@@ -186,10 +186,11 @@ function buildSkillStatus(
(skillConfig?.apiKey && entry.metadata?.primaryEnv === envName),
);
const isConfigSatisfied = (pathStr: string) => isConfigPathTruthy(config, pathStr);
const skillSource = entry.skill.sourceInfo.source;
const bundled =
bundledNames && bundledNames.size > 0
? bundledNames.has(entry.skill.name)
: entry.skill.source === "openclaw-bundled";
: skillSource === "openclaw-bundled";
const { emoji, homepage, required, missing, requirementsSatisfied, configChecks } =
evaluateEntryRequirementsForCurrentPlatform({
@@ -205,7 +206,7 @@ function buildSkillStatus(
return {
name: entry.skill.name,
description: entry.skill.description,
source: entry.skill.source,
source: skillSource,
bundled,
filePath: entry.skill.filePath,
baseDir: entry.skill.baseDir,

View File

@@ -24,7 +24,13 @@ function makeEntry(params: {
description: `desc:${params.name}`,
filePath: `/tmp/${params.name}/SKILL.md`,
baseDir: `/tmp/${params.name}`,
source: params.source ?? "openclaw-workspace",
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

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

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

@@ -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;
@@ -126,8 +125,7 @@ function renderCronFilterIcon(hiddenCount: number) {
"
>${hiddenCount}</span
>`
: ""
}
: ""}
</span>
`;
}
@@ -313,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>
@@ -945,9 +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}

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>
@@ -697,84 +695,70 @@ function renderGroupedMessage(
return html`
<div class="${bubbleClasses}">
${
hasActions
? html`<div class="chat-bubble-actions">
${hasActions
? html`<div class="chat-bubble-actions">
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
</div>`
: nothing
}
${
isToolMessage
? html`
: 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">
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
: 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)}">
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing
}
: nothing}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
</div>
</details>
`
: html`
: html`
${renderMessageImages(images)}
${
reasoningMarkdown
? html`<div class="chat-thinking">
${reasoningMarkdown
? html`<div class="chat-thinking">
${unsafeHTML(toSanitizedMarkdownHtml(reasoningMarkdown))}
</div>`
: nothing
}
${
jsonResult
? html`<details class="chat-json-collapse">
: 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)}">
: markdown
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
</div>`
: nothing
}
: nothing}
${hasToolCards ? renderCollapsedToolCards(toolCards, onOpenSidebar) : nothing}
`
}
`}
</div>
`;
}

View File

@@ -81,49 +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"
${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
}
: 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

@@ -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

@@ -115,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>
@@ -134,17 +134,13 @@ export function renderAgentOverview(params: {
@change=${(e: Event) =>
onModelChange(agent.id, (e.target as HTMLSelectElement).value || null)}
>
${
isDefault
? html`
<option value="">Not set</option>
`
: html`
${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>

View File

@@ -179,24 +179,19 @@ export function renderAgentChannels(params: {
</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`
${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);
@@ -222,33 +217,28 @@ export function renderAgentChannels(params: {
<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
}
${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>
`;
@@ -299,33 +289,26 @@ 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`
${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
}
${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"}">
@@ -350,8 +333,7 @@ export function renderAgentCron(params: {
`,
)}
</div>
`
}
`}
</section>
`;
}
@@ -394,60 +376,46 @@ export function renderAgentFiles(params: {
${params.agentFilesLoading ? "Loading…" : "Refresh"}
</button>
</div>
${
list
? html`<div class="muted mono" style="margin-top: 8px;">
${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;">
: 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`
: 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" : ""
}"
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
}
${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`
${!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>
@@ -482,15 +450,13 @@ export function renderAgentFiles(params: {
</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
}
${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
@@ -570,10 +536,8 @@ export function renderAgentFiles(params: {
</div>
</div>
</dialog>
`
}
`
}
`}
`}
</section>
`;
}

View File

@@ -200,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">
@@ -253,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;">
@@ -271,32 +261,31 @@ 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`
: html`
<div class="agent-tools-grid" style="margin-top: 16px;">
${params.toolsEffectiveResult?.groups.map(
(group) => html`
@@ -325,8 +314,7 @@ export function renderAgentTools(params: {
`,
)}
</div>
`
}
`}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
@@ -359,13 +347,11 @@ export function renderAgentTools(params: {
<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;"
${section.source === "plugin" && section.pluginId
? html`<span class="agent-pill" style="margin-left: 8px;"
>plugin:${section.pluginId}</span
>`
: nothing
}
: nothing}
</div>
<div class="agent-tools-list">
${section.tools.map((tool) => {
@@ -444,11 +430,9 @@ 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;">
@@ -499,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;">
@@ -548,12 +526,9 @@ 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`
${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, {
@@ -565,8 +540,7 @@ export function renderAgentSkills(params: {
}),
)}
</div>
`
}
`}
</section>
`;
}
@@ -622,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,29 +154,22 @@ 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`
${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)})`
: ""
}
${normalizeAgentLabel(agent)}${agentBadgeText(agent.id, defaultId)
? ` (${agentBadgeText(agent.id, defaultId)})`
: ""}
</option>
`,
)
}
)}
</select>
</div>
<div class="agents-toolbar-actions">
${
selectedAgent
? html`
${selectedAgent
? html`
<button
type="button"
class="btn btn--sm btn--ghost"
@@ -190,17 +183,14 @@ export function renderAgents(props: AgentsProps) {
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"
}
title=${defaultId && selectedAgent.id === defaultId
? "Already the default agent"
: "Set as the default agent"}
>
${defaultId && selectedAgent.id === defaultId ? "Default" : "Set Default"}
</button>
`
: nothing
}
: nothing}
<button
class="btn btn--sm agents-refresh-btn"
?disabled=${props.loading}
@@ -210,158 +200,142 @@ export function renderAgents(props: AgentsProps) {
</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`
${!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,
${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,
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
}
`
}
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>
`;
@@ -389,11 +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"

View File

@@ -108,20 +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
? 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;">
: nothing}
${error
? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">
${error}
</div>`
: nothing
}
: nothing}
</div>
`;
}
@@ -144,20 +140,16 @@ export function renderNostrProfileForm(params: {
}}
?disabled=${state.saving}
/>
${
help
? html`<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">
${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;">
: nothing}
${error
? html`<div style="font-size: 12px; color: var(--danger-color); margin-top: 2px;">
${error}
</div>`
: nothing
}
: nothing}
</div>
`;
};
@@ -199,16 +191,12 @@ export function renderNostrProfileForm(params: {
<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",
@@ -231,9 +219,8 @@ export function renderNostrProfileForm(params: {
placeholder: "https://example.com/avatar.jpg",
help: "HTTPS URL to your profile picture",
})}
${
state.showAdvanced
? html`
${state.showAdvanced
? html`
<div
style="border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 12px;"
>
@@ -261,8 +248,7 @@ export function renderNostrProfileForm(params: {
})}
</div>
`
: nothing
}
: nothing}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
@@ -288,15 +274,13 @@ export function renderNostrProfileForm(params: {
<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

@@ -80,16 +80,14 @@ export function renderNostrCard(params: {
<div>
<span class="label">Last inbound</span>
<span
>${
account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"
}</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>
`;
@@ -130,9 +128,8 @@ export function renderNostrCard(params: {
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}
@@ -141,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}
@@ -162,43 +156,33 @@ export function renderNostrCard(params: {
/>
</div>
`
: nothing
}
${
name
? html`<div><span class="label">Name</span><span>${name}</span></div>`
: nothing
}
${
displayName
? html`<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>
: 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}
${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>
`;
};
@@ -208,14 +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>
@@ -238,13 +221,10 @@ export function renderNostrCard(params: {
>
</div>
</div>
`
}
${
summaryLastError
? html`<div class="callout danger" style="margin-top: 12px;">${summaryLastError}</div>`
: nothing
}
`}
${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;">

View File

@@ -120,11 +120,9 @@ export function renderSingleAccountChannelCard(params: {
)}
</div>
${
params.lastError
? html`<div class="callout danger" style="margin-top: 12px;">${params.lastError}</div>`
: 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

@@ -41,16 +41,14 @@ export function renderTelegramCard(params: {
<div>
<span class="label">Last inbound</span>
<span
>${
account.lastInboundAt ? formatRelativeTimestamp(account.lastInboundAt) : "n/a"
}</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>
`;
@@ -67,19 +65,15 @@ export function renderTelegramCard(params: {
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
${
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;">
${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
}
: nothing}
${renderChannelConfigSection({ channelId: "telegram", props })}
<div class="row" style="margin-top: 12px;">

View File

@@ -82,11 +82,9 @@ export function renderChannels(props: ChannelsProps) {
${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
@@ -200,14 +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,13 +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,11 +305,9 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
>${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,18 +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">
${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
}
: nothing}
`,
configSection: renderChannelConfigSection({ channelId: "whatsapp", props }),
footer: html`<div class="row" style="margin-top: 14px; flex-wrap: wrap;">

View File

@@ -642,17 +642,15 @@ 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
${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">
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
<img src=${logoUrl} alt="OpenClaw" />
</div>`
}
</div>`}
<h2>${name}</h2>
<div class="agent-chat__badges">
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
@@ -743,9 +741,8 @@ function renderPinnedSection(
>${icons.chevronDown}</span
>
</button>
${
vs.pinnedExpanded
? html`
${vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${entries.map(
({ index, text, role }) => html`
@@ -771,8 +768,7 @@ function renderPinnedSection(
)}
</div>
`
: nothing
}
: nothing}
</div>
`;
}
@@ -805,11 +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>
@@ -851,9 +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)}
@@ -866,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>
`,
)}
@@ -954,46 +944,49 @@ export function renderChat(props: ChatProps) {
@click=${handleCodeBlockCopy}
>
<div class="chat-thread-inner">
${
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--short"></div>
</div>
</div>
</div>
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<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--short"></div>
</div>
${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--short"></div>
</div>
</div>
</div>
`
: nothing
}
<div class="chat-line user" style="margin-top: 12px">
<div class="chat-msg">
<div class="chat-bubble">
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
</div>
</div>
<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--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
}
${isEmpty && vs.searchOpen
? html` <div class="agent-chat__empty">No matching messages</div> `
: nothing}
${repeat(
chatItems,
(item) => item.key,
@@ -1171,9 +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"
@@ -1184,8 +1176,7 @@ export function renderChat(props: ChatProps) {
${icons.x}
</button>
`
: nothing
}
: nothing}
${renderSearchBar(requestUpdate)} ${renderPinnedSection(props, pinned, requestUpdate)}
<div class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}">
@@ -1196,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)}
@@ -1217,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">
@@ -1231,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"
@@ -1250,20 +1236,17 @@ 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`
${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">
@@ -1277,11 +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))}
@@ -1309,13 +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();
@@ -1362,17 +1342,15 @@ 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`
${canAbort
? nothing
: html`
<button
class="btn btn--ghost"
@click=${props.onNewSession}
@@ -1381,8 +1359,7 @@ export function renderChat(props: ChatProps) {
>
${icons.plus}
</button>
`
}
`}
<button
class="btn btn--ghost"
@click=${() => exportMarkdown(props)}
@@ -1393,9 +1370,8 @@ export function renderChat(props: ChatProps) {
${icons.download}
</button>
${
canAbort && (isBusy || props.sending)
? html`
${canAbort && (isBusy || props.sending)
? html`
<button
class="chat-send-btn chat-send-btn--stop"
@click=${props.onAbort}
@@ -1405,7 +1381,7 @@ export function renderChat(props: ChatProps) {
${icons.stop}
</button>
`
: html`
: html`
<button
class="chat-send-btn"
@click=${() => {
@@ -1420,8 +1396,7 @@ export function renderChat(props: ChatProps) {
>
${icons.send}
</button>
`
}
`}
</div>
</div>
</div>

View File

@@ -220,16 +220,15 @@ export function renderCommandPalette(props: CommandPaletteProps) {
}}
/>
<div class="cmd-palette__results">
${
grouped.length === 0
? html`<div class="cmd-palette__empty">
${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`
: grouped.map(
([category, groupedItems]) => html`
<div class="cmd-palette__group-label">
${CATEGORY_LABELS[category] ?? category}
</div>
@@ -247,19 +246,16 @@ export function renderCommandPalette(props: CommandPaletteProps) {
>
<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
? html`<span class="cmd-palette__item-desc muted"
>${item.description}</span
>`
: nothing
}
: 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)}
@@ -539,9 +537,10 @@ export function renderNode(params: {
(opt) => html`
<button
type="button"
class="cfg-segmented__btn ${
opt === resolvedValue || String(opt) === String(resolvedValue) ? "active" : ""
}"
class="cfg-segmented__btn ${opt === resolvedValue ||
String(opt) === String(resolvedValue)
? "active"
: ""}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
@@ -694,9 +693,8 @@ function renderTextInput(params: {
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${
schema.default !== undefined
? html`
${schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
@@ -707,8 +705,7 @@ function renderTextInput(params: {
</button>
`
: nothing
}
: nothing}
</div>
</div>
`;
@@ -950,24 +947,22 @@ 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
@@ -1064,12 +1059,9 @@ 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`
${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`
@@ -1110,8 +1102,7 @@ function renderArray(params: {
`,
)}
</div>
`
}
`}
</div>
`;
}
@@ -1184,12 +1175,9 @@ function renderMapField(params: {
</button>
</div>
${
visibleEntries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
${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];
@@ -1241,17 +1229,16 @@ function renderMapField(params: {
</button>
</div>
<div class="cfg-map__item-value">
${
anySchema
? html`
${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"
}
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}
@@ -1286,28 +1273,26 @@ function renderMapField(params: {
})}
</div>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
}
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</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,9 +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) {
@@ -593,13 +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"
${opt.id === props.theme
? html`<span class="settings-theme-card__check" aria-hidden="true"
>${icons.check}</span
>`
: nothing
}
: nothing}
</button>
`,
)}
@@ -646,16 +644,14 @@ function renderAppearanceSection(props: ConfigProps) {
${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>
@@ -790,9 +786,8 @@ export function renderConfig(props: ConfigProps) {
<main class="config-main">
<div class="config-actions">
<div class="config-actions__left">
${
showModeToggle
? html`
${showModeToggle
? html`
<div class="config-mode-toggle">
<button
class="config-mode-toggle__btn ${formMode === "form" ? "active" : ""}"
@@ -810,28 +805,20 @@ export function renderConfig(props: ConfigProps) {
</button>
</div>
`
: nothing
}
${
hasChanges
? html`
: nothing}
${hasChanges
? html`
<span class="config-changes-badge"
>${
formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`
}</span
>${formMode === "raw"
? "Unsaved changes"
: `${diff.length} unsaved change${diff.length !== 1 ? "s" : ""}`}</span
>
`
: html`
<span class="config-status muted">No changes</span>
`
}
: html` <span class="config-status muted">No changes</span> `}
</div>
<div class="config-actions__right">
${
props.onOpenFile
? html`
${props.onOpenFile
? html`
<button
class="btn btn--sm"
title=${props.configPath ? `Open ${props.configPath}` : "Open config file"}
@@ -840,8 +827,7 @@ export function renderConfig(props: ConfigProps) {
${icons.fileText} Open
</button>
`
: nothing
}
: nothing}
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"}
</button>
@@ -858,9 +844,8 @@ export function renderConfig(props: ConfigProps) {
</div>
<div class="config-top-tabs">
${
formMode === "form"
? html`
${formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
@@ -882,9 +867,8 @@ export function renderConfig(props: ConfigProps) {
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
${props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@@ -893,13 +877,11 @@ export function renderConfig(props: ConfigProps) {
×
</button>
`
: nothing
}
: nothing}
</div>
</div>
`
: nothing
}
: nothing}
<div class="config-top-tabs__scroller" role="tablist" aria-label="Settings sections">
${topTabs.map(
@@ -918,9 +900,8 @@ 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"
@@ -953,13 +934,11 @@ export function renderConfig(props: ConfigProps) {
</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>
@@ -993,32 +972,27 @@ export function renderConfig(props: ConfigProps) {
</div>
</details>
`
: nothing
}
${
activeSectionMeta && formMode === "form"
? html`
: 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">
${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;
@@ -1041,83 +1015,75 @@ export function renderConfig(props: ConfigProps) {
Peek
</button>
`
: nothing
}
: nothing}
</div>
`
: nothing
}
: nothing}
<!-- Form content -->
<div class="config-content">
${
props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${props.activeSection === "__appearance__"
? includeVirtualSections
? renderAppearanceSection(props)
: nothing
: formMode === "form"
? html`
${showAppearanceOnRoot ? renderAppearanceSection(props) : nothing}
${
props.schemaLoading
${props.schemaLoading
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
toggleSensitivePathReveal(path);
requestUpdate();
},
})}
`
: (() => {
const sensitiveCount = countSensitiveConfigValues(
props.formValue,
[],
props.uiHints,
);
const blurred = sensitiveCount > 0 && !cvs.rawRevealed;
return html`
${formUnsafe
? html`
<div class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
<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>
`
: renderConfigForm({
schema: analysis.schema,
uiHints: props.uiHints,
value: props.formValue,
disabled: props.loading || !props.formValue,
unsupportedPaths: analysis.unsupportedPaths,
onPatch: props.onFormPatch,
searchQuery: props.searchQuery,
activeSection: props.activeSection,
activeSubsection: effectiveSubsection,
revealSensitive:
props.activeSection === "env" ? envSensitiveVisible : false,
isSensitivePathRevealed,
onToggleSensitivePath: (path) => {
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
}
: nothing}
<div class="field config-raw-field">
<span style="display:flex;align-items:center;gap:8px;">
Raw config (JSON/JSON5)
${
sensitiveCount > 0
? html`
${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=${() => {
@@ -1128,18 +1094,16 @@ export function renderConfig(props: ConfigProps) {
${blurred ? icons.eyeOff : icons.eye}
</button>
`
: nothing
}
: nothing}
</span>
${
blurred
? html`
${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`
: html`
<textarea
placeholder="Raw config (JSON/JSON5)"
.value=${props.raw}
@@ -1147,21 +1111,17 @@ export function renderConfig(props: ConfigProps) {
props.onRawChange((e.target as HTMLTextAreaElement).value);
}}
></textarea>
`
}
`}
</div>
`;
})()
}
})()}
</div>
${
props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;">
${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>
`;

View File

@@ -341,14 +341,12 @@ function focusFormField(id: string) {
function renderFieldLabel(text: string, required = false) {
return html`<span>
${text}
${
required
? html`
${required
? html`
<span class="cron-required-marker" aria-hidden="true">*</span>
<span class="cron-required-sr">${t("cron.form.requiredSr")}</span>
`
: nothing
}
: nothing}
</span>`;
}
@@ -402,13 +400,11 @@ export function renderCron(props: CronProps) {
<div class="cron-summary-label">${t("cron.summary.enabled")}</div>
<div class="cron-summary-value">
<span class=${`chip ${props.status?.enabled ? "chip-ok" : "chip-danger"}`}>
${
props.status
? props.status.enabled
? t("cron.summary.yes")
: t("cron.summary.no")
: t("common.na")
}
${props.status
? props.status.enabled
? t("cron.summary.yes")
: t("cron.summary.no")
: t("common.na")}
</span>
</div>
</div>
@@ -547,18 +543,15 @@ export function renderCron(props: CronProps) {
</button>
</label>
</div>
${
props.jobs.length === 0
? html` <div class="muted" style="margin-top: 12px">${t("cron.jobs.noMatching")}</div> `
: html`
${props.jobs.length === 0
? html` <div class="muted" style="margin-top: 12px">${t("cron.jobs.noMatching")}</div> `
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
</div>
`
}
${
props.jobsHasMore
? html`
`}
${props.jobsHasMore
? html`
<div class="row" style="margin-top: 12px">
<button
class="btn"
@@ -569,8 +562,7 @@ export function renderCron(props: CronProps) {
</button>
</div>
`
: nothing
}
: nothing}
</section>
<section class="card">
@@ -581,11 +573,9 @@ export function renderCron(props: CronProps) {
<div>
<div class="card-title">${t("cron.runs.title")}</div>
<div class="card-sub">
${
props.runsScope === "all"
? t("cron.runs.subtitleAll")
: t("cron.runs.subtitleJob", { title: selectedRunTitle })
}
${props.runsScope === "all"
? t("cron.runs.subtitleAll")
: t("cron.runs.subtitleJob", { title: selectedRunTitle })}
</div>
</div>
<div class="muted">
@@ -676,24 +666,21 @@ export function renderCron(props: CronProps) {
})}
</div>
</div>
${
props.runsScope === "job" && props.runsJobId == null
? html`
${props.runsScope === "job" && props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px">${t("cron.runs.selectJobHint")}</div>
`
: runs.length === 0
? html`
: runs.length === 0
? html`
<div class="muted" style="margin-top: 12px">${t("cron.runs.noMatching")}</div>
`
: html`
: html`
<div class="list" style="margin-top: 12px;">
${runs.map((entry) => renderRun(entry, props.basePath, props.onNavigateToChat))}
</div>
`
}
${
(props.runsScope === "all" || props.runsJobId != null) && props.runsHasMore
? html`
`}
${(props.runsScope === "all" || props.runsJobId != null) && props.runsHasMore
? html`
<div class="row" style="margin-top: 12px">
<button
class="btn"
@@ -704,8 +691,7 @@ export function renderCron(props: CronProps) {
</button>
</div>
`
: nothing
}
: nothing}
</section>
</div>
@@ -847,16 +833,13 @@ export function renderCron(props: CronProps) {
<option value="agentTurn">${t("cron.form.agentTurn")}</option>
</select>
<div class="cron-help">
${
props.form.payloadKind === "systemEvent"
? t("cron.form.systemEventHelp")
: t("cron.form.agentTurnHelp")
}
${props.form.payloadKind === "systemEvent"
? t("cron.form.systemEventHelp")
: t("cron.form.agentTurnHelp")}
</div>
</label>
${
isAgentTurn
? html`
${isAgentTurn
? html`
<label class="field">
${renderFieldLabel(t("cron.form.timeoutSeconds"))}
<input
@@ -881,8 +864,7 @@ export function renderCron(props: CronProps) {
)}
</label>
`
: nothing
}
: nothing}
</div>
<label class="field cron-span-2">
${renderFieldLabel(
@@ -923,19 +905,16 @@ export function renderCron(props: CronProps) {
.value as CronFormState["deliveryMode"],
})}
>
${
supportsAnnounce
? html` <option value="announce">${t("cron.form.announceDefault")}</option> `
: nothing
}
${supportsAnnounce
? html` <option value="announce">${t("cron.form.announceDefault")}</option> `
: nothing}
<option value="webhook">${t("cron.form.webhookPost")}</option>
<option value="none">${t("cron.form.noneInternal")}</option>
</select>
<div class="cron-help">${t("cron.form.deliveryHelp")}</div>
</label>
${
selectedDeliveryMode !== "none"
? html`
${selectedDeliveryMode !== "none"
? html`
<label class="field ${selectedDeliveryMode === "webhook" ? "cron-span-2" : ""}">
${renderFieldLabel(
selectedDeliveryMode === "webhook"
@@ -943,9 +922,8 @@ export function renderCron(props: CronProps) {
: t("cron.form.channel"),
selectedDeliveryMode === "webhook",
)}
${
selectedDeliveryMode === "webhook"
? html`
${selectedDeliveryMode === "webhook"
? html`
<input
id="cron-delivery-to"
.value=${props.form.deliveryTo}
@@ -963,7 +941,7 @@ export function renderCron(props: CronProps) {
placeholder=${t("cron.form.webhookPlaceholder")}
/>
`
: html`
: html`
<select
id="cron-delivery-channel"
.value=${props.form.deliveryChannel || "last"}
@@ -979,17 +957,13 @@ export function renderCron(props: CronProps) {
</option>`,
)}
</select>
`
}
${
selectedDeliveryMode === "announce"
? html` <div class="cron-help">${t("cron.form.channelHelp")}</div> `
: html` <div class="cron-help">${t("cron.form.webhookHelp")}</div> `
}
`}
${selectedDeliveryMode === "announce"
? html` <div class="cron-help">${t("cron.form.channelHelp")}</div> `
: html` <div class="cron-help">${t("cron.form.webhookHelp")}</div> `}
</label>
${
selectedDeliveryMode === "announce"
? html`
${selectedDeliveryMode === "announce"
? html`
<label class="field cron-span-2">
${renderFieldLabel(t("cron.form.to"))}
<input
@@ -1005,19 +979,15 @@ export function renderCron(props: CronProps) {
<div class="cron-help">${t("cron.form.toHelp")}</div>
</label>
`
: nothing
}
${
selectedDeliveryMode === "webhook"
? renderFieldError(
props.fieldErrors.deliveryTo,
errorIdForField("deliveryTo"),
)
: nothing
}
: nothing}
${selectedDeliveryMode === "webhook"
? renderFieldError(
props.fieldErrors.deliveryTo,
errorIdForField("deliveryTo"),
)
: nothing}
`
: nothing
}
: nothing}
</div>
</section>
@@ -1062,9 +1032,8 @@ export function renderCron(props: CronProps) {
/>
<div class="cron-help">Optional routing key for job delivery and wake routing.</div>
</label>
${
isCronSchedule
? html`
${isCronSchedule
? html`
<label class="field checkbox cron-checkbox cron-span-2">
<input
type="checkbox"
@@ -1118,11 +1087,9 @@ export function renderCron(props: CronProps) {
</label>
</div>
`
: nothing
}
${
isAgentTurn
? html`
: nothing}
${isAgentTurn
? html`
<label class="field cron-span-2">
${renderFieldLabel("Account ID")}
<input
@@ -1183,11 +1150,9 @@ export function renderCron(props: CronProps) {
<div class="cron-help">${t("cron.form.thinkingHelp")}</div>
</label>
`
: nothing
}
${
isAgentTurn
? html`
: nothing}
${isAgentTurn
? html`
<label class="field cron-span-2">
${renderFieldLabel("Failure alerts")}
<select
@@ -1206,9 +1171,8 @@ export function renderCron(props: CronProps) {
Control when this job sends repeated-failure alerts.
</div>
</label>
${
props.form.failureAlertMode === "custom"
? html`
${props.form.failureAlertMode === "custom"
? html`
<label class="field">
${renderFieldLabel("Alert after")}
<input
@@ -1237,9 +1201,9 @@ export function renderCron(props: CronProps) {
<input
id="cron-failure-alert-cooldown-seconds"
.value=${props.form.failureAlertCooldownSeconds}
aria-invalid=${
props.fieldErrors.failureAlertCooldownSeconds ? "true" : "false"
}
aria-invalid=${props.fieldErrors.failureAlertCooldownSeconds
? "true"
: "false"}
aria-describedby=${ifDefined(
props.fieldErrors.failureAlertCooldownSeconds
? errorIdForField("failureAlertCooldownSeconds")
@@ -1315,14 +1279,11 @@ export function renderCron(props: CronProps) {
/>
</label>
`
: nothing
}
: nothing}
`
: nothing
}
${
selectedDeliveryMode !== "none"
? html`
: nothing}
${selectedDeliveryMode !== "none"
? html`
<label class="field checkbox cron-checkbox cron-span-2">
<input
type="checkbox"
@@ -1338,14 +1299,12 @@ export function renderCron(props: CronProps) {
<div class="cron-help">${t("cron.form.bestEffortHelp")}</div>
</label>
`
: nothing
}
: nothing}
</div>
</details>
</div>
${
blockedByValidation
? html`
${blockedByValidation
? html`
<div class="cron-form-status" role="status" aria-live="polite">
<div class="cron-form-status__title">${t("cron.form.cantAddYet")}</div>
<div class="cron-help">${t("cron.form.fillRequired")}</div>
@@ -1366,36 +1325,29 @@ export function renderCron(props: CronProps) {
</ul>
</div>
`
: nothing
}
: nothing}
<div class="row cron-form-actions">
<button
class="btn primary"
?disabled=${props.busy || !props.canSubmit}
@click=${props.onAdd}
>
${
props.busy
? t("cron.form.saving")
: isEditing
? t("cron.form.saveChanges")
: t("cron.form.addJob")
}
${props.busy
? t("cron.form.saving")
: isEditing
? t("cron.form.saveChanges")
: t("cron.form.addJob")}
</button>
${
submitDisabledReason
? html`<div class="cron-submit-reason" aria-live="polite">${submitDisabledReason}</div>`
: nothing
}
${
isEditing
? html`
${submitDisabledReason
? html`<div class="cron-submit-reason" aria-live="polite">${submitDisabledReason}</div>`
: nothing}
${isEditing
? html`
<button class="btn" ?disabled=${props.busy} @click=${props.onCancelEdit}>
${t("cron.form.cancel")}
</button>
`
: nothing
}
: nothing}
</div>
</section>
</section>
@@ -1522,13 +1474,11 @@ function renderJob(job: CronJob, props: CronProps) {
<div class="list-title">${job.name}</div>
<div class="list-sub">${formatCronSchedule(job)}</div>
${renderJobPayload(job)}
${
job.agentId
? html`<div class="muted cron-job-agent">
${job.agentId
? html`<div class="muted cron-job-agent">
${t("cron.jobDetail.agent")}: ${job.agentId}
</div>`
: nothing
}
: nothing}
</div>
<div class="list-meta">${renderJobState(job)}</div>
<div class="cron-job-footer">
@@ -1639,14 +1589,12 @@ function renderJobPayload(job: CronJob) {
<span class="cron-job-detail-label">${t("cron.jobDetail.prompt")}</span>
<span class="muted cron-job-detail-value">${job.payload.message}</span>
</div>
${
delivery
? html`<div class="cron-job-detail">
${delivery
? html`<div class="cron-job-detail">
<span class="cron-job-detail-label">${t("cron.jobDetail.delivery")}</span>
<span class="muted cron-job-detail-value">${delivery.mode}${deliveryTarget}</span>
</div>`
: nothing
}
: nothing}
`;
}
@@ -1770,20 +1718,15 @@ function renderRun(
</div>
<div class="list-meta cron-run-entry__meta">
<div>${formatMs(entry.ts)}</div>
${
typeof entry.runAtMs === "number"
? html`<div class="muted">${t("cron.runEntry.runAt")} ${formatMs(entry.runAtMs)}</div>`
: nothing
}
${typeof entry.runAtMs === "number"
? html`<div class="muted">${t("cron.runEntry.runAt")} ${formatMs(entry.runAtMs)}</div>`
: nothing}
<div class="muted">${entry.durationMs ?? 0}ms</div>
${
typeof entry.nextRunAtMs === "number"
? html`<div class="muted">${formatRunNextLabel(entry.nextRunAtMs)}</div>`
: nothing
}
${
chatUrl
? html`<div>
${typeof entry.nextRunAtMs === "number"
? html`<div class="muted">${formatRunNextLabel(entry.nextRunAtMs)}</div>`
: nothing}
${chatUrl
? html`<div>
<a
class="session-link"
href=${chatUrl}
@@ -1806,8 +1749,7 @@ function renderRun(
>${t("cron.runEntry.openRunChat")}</a
>
</div>`
: nothing
}
: nothing}
${entry.error ? html`<div class="muted">${entry.error}</div>` : nothing}
${entry.deliveryError ? html`<div class="muted">${entry.deliveryError}</div>` : nothing}
</div>

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>
@@ -80,13 +78,9 @@ export function renderDebug(props: DebugProps) {
@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>
@@ -103,16 +97,12 @@ 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>
@@ -127,12 +117,9 @@ ${JSON.stringify(props.models ?? [], null, 2)}</pre
<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`
@@ -150,8 +137,7 @@ ${formatEventPayload(evt.payload)}</pre
`,
)}
</div>
`
}
`}
</section>
`;
}

View File

@@ -39,11 +39,9 @@ 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">
@@ -52,11 +50,9 @@ export function renderExecApprovalPrompt(state: AppViewState) {
${renderMetaRow("Resolved", request.resolvedPath)}
${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

@@ -42,24 +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>
`;
@@ -94,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

@@ -99,13 +99,11 @@ export function renderLoginGate(state: AppViewState) {
${t("common.connect")}
</button>
</div>
${
state.lastError
? html`<div class="callout danger" style="margin-top: 14px;">
${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">

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

@@ -18,22 +18,18 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
<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">
: props.content
? html`<div class="sidebar-markdown">
${unsafeHTML(toSanitizedMarkdownHtml(props.content))}
</div>`
: html`
<div class="muted">No content available</div>
`
}
: html` <div class="muted">No content available</div> `}
</div>
</div>
`;

View File

@@ -211,23 +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`
: html`
${renderExecApprovalsTabs(state)} ${renderExecApprovalsPolicy(state)}
${
state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)
}
`
}
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
? nothing
: renderExecApprovalsAllowlist(state)}
`}
</section>
`;
}
@@ -262,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
@@ -285,17 +280,12 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</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>
`;
}
@@ -306,9 +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
@@ -369,13 +359,11 @@ 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}>
@@ -409,13 +397,11 @@ 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}>
@@ -431,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">
@@ -453,13 +437,11 @@ 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}>
@@ -475,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">
@@ -497,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>
@@ -535,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

@@ -62,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>
`;
@@ -89,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>
`;
@@ -165,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>
`;
@@ -198,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>
`;
@@ -285,24 +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">
@@ -329,26 +306,17 @@ function renderBindings(state: BindingState) {
)}
</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>
`;
}
@@ -363,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">

View File

@@ -42,17 +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
}
: nothing}
</div>
`,
)}

View File

@@ -136,9 +136,8 @@ export function renderOverviewCards(props: OverviewCardsProps) {
return html`
<section class="ov-cards">${cards.map((c) => renderStatCard(c, props.onNavigate))}</section>
${
sessions.length > 0
? html`
${sessions.length > 0
? html`
<section class="ov-recent">
<h3 class="ov-recent__title">${t("overview.cards.recentSessions")}</h3>
<ul class="ov-recent__list">
@@ -158,7 +157,6 @@ export function renderOverviewCards(props: OverviewCardsProps) {
</ul>
</section>
`
: nothing
}
: nothing}
`;
}

View File

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

View File

@@ -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
@@ -308,14 +306,13 @@ export function renderOverview(props: OverviewProps) {
<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
>${isTrustedProxy
? t("overview.access.trustedProxy")
: t("overview.access.connectHint")}</span
>
</div>
${
!props.connected
? html`
${!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">
@@ -342,8 +339,7 @@ export function renderOverview(props: OverviewProps) {
</div>
</div>
`
: nothing
}
: nothing}
</div>
<div class="card">
@@ -367,26 +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 ?? ""}
</div>`
: html`
: html`
<div class="callout" style="margin-top: 14px">
${t("overview.snapshot.channelsHint")}
</div>
`
}
`}
</div>
</section>

View File

@@ -216,11 +216,9 @@ export function renderSessions(props: SessionsProps) {
<div>
<div class="card-title">Sessions</div>
<div class="card-sub">
${
props.result
? `Store: ${props.result.path}`
: "Active session keys and per-session overrides."
}
${props.result
? `Store: ${props.result.path}`
: "Active session keys and per-session overrides."}
</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
@@ -288,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">
@@ -306,9 +302,8 @@ export function renderSessions(props: SessionsProps) {
</div>
</div>
${
props.selectedKeys.size > 0
? html`
${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>
@@ -321,26 +316,20 @@ export function renderSessions(props: SessionsProps) {
</button>
</div>
`
: nothing
}
: 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) {
@@ -351,8 +340,7 @@ export function renderSessions(props: SessionsProps) {
}}
aria-label="Select all on page"
/>`
: nothing
}
: nothing}
</th>
${sortHeader("key", "Key", "data-table-key-col")}
<th>Label</th>
@@ -365,34 +353,34 @@ 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`
${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)}
@@ -419,8 +407,7 @@ export function renderSessions(props: SessionsProps) {
</div>
</div>
`
: nothing
}
: nothing}
</div>
</section>
`;
@@ -480,9 +467,8 @@ function renderRow(
</td>
<td class="data-table-key-col">
<div class="mono session-key-cell">
${
canLink
? html`<a
${canLink
? html`<a
href=${chatUrl}
class="session-link"
@click=${(e: MouseEvent) => {
@@ -503,13 +489,10 @@ function renderRow(
}}
>${row.key}</a
>`
: row.key
}
${
showDisplayName
? html`<span class="muted session-key-display-name">${displayName}</span>`
: nothing
}
: 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

@@ -159,21 +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`
${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."
}
${!props.connected && !props.report
? "Not connected to gateway."
: "No skills found."}
</div>
`
: html`
: html`
<div class="agent-skills-groups" style="margin-top: 16px;">
${groups.map((group) => {
return html`
@@ -189,8 +186,7 @@ export function renderSkills(props: SkillsProps) {
`;
})}
</div>
`
}
`}
</section>
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing}
@@ -271,9 +267,8 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
${renderSkillStatusChips({ skill, showBundledBadge })}
</div>
${
missing.length > 0
? html`
${missing.length > 0
? html`
<div
class="callout"
style="border-color: var(--warn-subtle); background: var(--warn-subtle); color: var(--warn);"
@@ -282,15 +277,12 @@ function renderSkillDetail(skill: SkillStatusEntry, props: SkillsProps) {
<div>${missing.join(", ")}</div>
</div>
`
: nothing
}
${
reasons.length > 0
? html`
: 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">
@@ -305,29 +297,24 @@ 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
@@ -363,8 +350,7 @@ 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);"

View File

@@ -114,13 +114,11 @@ function renderSessionSummary(
})) ?? [];
return html`
${
badges.length > 0
? html`<div class="usage-badges">
${badges.length > 0
? html`<div class="usage-badges">
${badges.map((b) => html`<span class="usage-badge">${b}</span>`)}
</div>`
: nothing
}
: nothing}
<div class="session-summary-grid">
<div class="stat session-summary-card">
<div class="session-summary-title">${t("usage.overview.messages")}</div>
@@ -145,10 +143,8 @@ 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)}
@@ -275,17 +271,14 @@ 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`
${usage
? html`
<span
><strong>${formatTokens(headerStats.totalTokens)}</strong> ${t(
"usage.metrics.tokens",
@@ -293,8 +286,7 @@ function renderSessionDetailPanel(
>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing
}
: nothing}
</div>
<button
class="btn btn--sm btn--ghost"
@@ -488,9 +480,8 @@ 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`
${hasSelection
? html`
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn active"
@@ -500,8 +491,7 @@ function renderTimeSeriesCompact(
</button>
</div>
`
: nothing
}
: nothing}
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${!isCumulative ? "active" : ""}"
@@ -516,9 +506,8 @@ function renderTimeSeriesCompact(
${t("usage.details.cumulative")}
</button>
</div>
${
!isCumulative
? html`
${!isCumulative
? html`
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${breakdownMode === "total" ? "active" : ""}"
@@ -534,8 +523,7 @@ function renderTimeSeriesCompact(
</button>
</div>
`
: nothing
}
: nothing}
</div>
</div>
<div class="timeseries-chart-wrapper">
@@ -574,14 +562,12 @@ function renderTimeSeriesCompact(
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];
@@ -735,9 +721,8 @@ function renderTimeSeriesCompact(
})()}
</div>
<div class="timeseries-summary">
${
hasSelection
? html`
${hasSelection
? html`
<span class="timeseries-summary__range">
${t("usage.details.turnRange", {
start: String(rangeStartIdx + 1),
@@ -759,13 +744,11 @@ function renderTimeSeriesCompact(
)}
· ${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`
${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">
@@ -809,8 +792,7 @@ function renderTimeSeriesCompact(
</div>
</div>
`
: nothing
}
: nothing}
</div>
`;
}
@@ -869,13 +851,11 @@ function renderContextPanel(
<div class="card-title usage-section-title">
${t("usage.details.systemPromptBreakdown")}
</div>
${
hasMore
? html`<button class="btn btn--sm" @click=${onToggleExpanded}>
${hasMore
? html`<button class="btn btn--sm" @click=${onToggleExpanded}>
${showAll ? t("usage.details.collapse") : t("usage.details.expandAll")}
</button>`
: nothing
}
: nothing}
</div>
<p class="context-weight-desc">${contextPct || t("usage.details.baseContextPerMessage")}</p>
<div class="context-stacked-bar">
@@ -922,11 +902,10 @@ function renderContextPanel(
${t("usage.breakdown.total")}: ~${formatTokens(totalContextTokens)}
</div>
<div class="context-breakdown-grid">
${
skillsList.length > 0
? (() => {
const more = skillsList.length - skillsTop.length;
return html`
${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})
@@ -941,25 +920,21 @@ function renderContextPanel(
`,
)}
</div>
${
more > 0
? html`
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
: nothing}
</div>
`;
})()
: nothing
}
${
toolsList.length > 0
? (() => {
const more = toolsList.length - toolsTop.length;
return html`
})()
: 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})
@@ -976,25 +951,21 @@ function renderContextPanel(
`,
)}
</div>
${
more > 0
? html`
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
: nothing}
</div>
`;
})()
: nothing
}
${
filesList.length > 0
? (() => {
const more = filesList.length - filesTop.length;
return html`
})()
: 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})
@@ -1011,20 +982,17 @@ function renderContextPanel(
`,
)}
</div>
${
more > 0
? html`
${more > 0
? html`
<div class="context-breakdown-more">
${t("usage.sessions.more", { count: String(more) })}
</div>
`
: nothing
}
: nothing}
</div>
`;
})()
: nothing
}
})()
: nothing}
</div>
</div>
`;
@@ -1209,9 +1177,8 @@ function renderSessionLogsCompact(
${log.tokens ? html`<span>${formatTokens(log.tokens)}</span>` : nothing}
</div>
<div class="session-log-content">${cleanContent}</div>
${
toolInfo.tools.length > 0
? html`
${toolInfo.tools.length > 0
? html`
<details class="session-log-tools" ?open=${expandedAll}>
<summary>${toolInfo.summary}</summary>
<div class="session-log-tools-list">
@@ -1223,20 +1190,17 @@ function renderSessionLogsCompact(
</div>
</details>
`
: nothing
}
: nothing}
</div>
`;
})}
${
filteredEntries.length === 0
? html`
${filteredEntries.length === 0
? html`
<div class="usage-empty-block usage-empty-block--compact">
${t("usage.details.noMessagesMatch")}
</div>
`
: nothing
}
: 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,9 +259,8 @@ function renderDailyChartCompact(
class="daily-bar-wrapper ${isSelected ? "selected" : ""}"
@click=${(e: MouseEvent) => onSelectDay(d.date, e.shiftKey)}
>
${
dailyChartMode === "by-type"
? html`
${dailyChartMode === "by-type"
? html`
<div
class="daily-bar daily-bar--stacked"
style="height: ${heightPx.toFixed(0)}px;"
@@ -287,19 +278,16 @@ function renderDailyChartCompact(
})()}
</div>
`
: html` <div class="daily-bar" style="height: ${heightPx.toFixed(0)}px"></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>
`;
@@ -330,34 +318,34 @@ function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost"
<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)
}"
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)
}"
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)
}"
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)
}"
title="${t("usage.breakdown.cacheRead")}: ${isTokenMode
? formatTokens(totals.cacheRead)
: formatCost(breakdown.cacheRead.cost)}"
></div>
</div>
<div class="cost-breakdown-legend">
@@ -371,15 +359,15 @@ function renderCostBreakdownCompact(totals: UsageTotals, mode: "tokens" | "cost"
>
<span class="legend-item"
><span class="legend-dot cache-write"></span>${t("usage.breakdown.cacheWrite")}
${
isTokenMode ? formatTokens(totals.cacheWrite) : formatCost(breakdown.cacheWrite.cost)
}</span
${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
${isTokenMode
? formatTokens(totals.cacheRead)
: formatCost(breakdown.cacheRead.cost)}</span
>
</div>
<div class="cost-breakdown-total">
@@ -398,10 +386,9 @@ 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`
${items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class="usage-list">
${items.map(
(item) => html`
@@ -415,8 +402,7 @@ function renderInsightList(
`,
)}
</div>
`
}
`}
</div>
`;
}
@@ -435,10 +421,9 @@ function renderPeakErrorList(
return html`
<div class=${cardClass}>
<div class="usage-insight-title">${title}</div>
${
items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
${items.length === 0
? html`<div class="muted">${emptyLabel}</div>`
: html`
<div class=${listClass}>
${items.map(
(item) => html`
@@ -450,8 +435,7 @@ function renderPeakErrorList(
`,
)}
</div>
`
}
`}
</div>
`;
}
@@ -816,11 +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
@@ -855,11 +837,9 @@ 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">
@@ -910,55 +890,46 @@ function renderSessionsCard(
<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`
${selectedCount > 0
? html`
<button class="btn btn--sm" @click=${onClearSessions}>
${t("usage.sessions.clearSelection")}
</button>
`
: nothing
}
: nothing}
</div>
${
sessionsTab === "recent"
? recentEntries.length === 0
? html` <div class="usage-empty-block">${t("usage.sessions.noRecent")}</div> `
: html`
${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`
: 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`
${sessions.length > 50
? html`
<div class="usage-more-sessions">
${t("usage.sessions.more", { count: String(sessions.length - 50) })}
</div>
`
: nothing
}
: nothing}
</div>
`
}
${
selectedCount > 1
? html`
`}
${selectedCount > 1
? html`
<div class="sessions-selected-group">
<div class="sessions-card-count">
${t("usage.sessions.selected", { count: String(selectedCount) })}
@@ -968,8 +939,7 @@ function renderSessionsCard(
</div>
</div>
`
: nothing
}
: nothing}
</div>
`;
}

View File

@@ -376,11 +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">
@@ -449,21 +447,16 @@ 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`
${displayTotals
? html`
<span class="usage-metric-badge">
<strong>${formatTokens(displayTotals.totalTokens)}</strong>
${t("usage.metrics.tokens")}
@@ -474,15 +467,12 @@ export function renderUsage(props: UsageProps) {
</span>
<span class="usage-metric-badge">
<strong>${displaySessionCount}</strong>
${
displaySessionCount === 1
? t("usage.metrics.session")
: t("usage.metrics.sessions")
}
${displaySessionCount === 1
? t("usage.metrics.session")
: t("usage.metrics.sessions")}
</span>
`
: nothing
}
: nothing}
<button
class="btn btn--sm usage-pin-btn ${display.headerPinned ? "active" : ""}"
title=${display.headerPinned ? t("usage.filters.unpin") : t("usage.filters.pin")}
@@ -664,24 +654,20 @@ export function renderUsage(props: UsageProps) {
>
${t("usage.query.apply")}
</button>
${
hasDraftQuery || hasQuery
? html`
${hasDraftQuery || hasQuery
? html`
<button class="btn btn--sm" @click=${filterActions.onClearQuery}>
${t("usage.filters.clear")}
</button>
`
: nothing
}
: 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>
@@ -693,9 +679,8 @@ 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`
${queryTerms.length > 0
? html`
<div class="usage-query-chips">
${queryTerms.map((term) => {
const label = term.raw;
@@ -716,11 +701,9 @@ export function renderUsage(props: UsageProps) {
})}
</div>
`
: nothing
}
${
querySuggestions.length > 0
? html`
: nothing}
${querySuggestions.length > 0
? html`
<div class="usage-query-suggestions">
${querySuggestions.map(
(suggestion) => html`
@@ -737,35 +720,29 @@ export function renderUsage(props: UsageProps) {
)}
</div>
`
: nothing
}
${
queryWarnings.length > 0
? html`
: nothing}
${queryWarnings.length > 0
? html`
<div class="callout warning usage-callout usage-callout--tight">
${queryWarnings.join(" · ")}
</div>
`
: nothing
}
: nothing}
</div>
${
data.error ? html`<div class="callout danger usage-callout">${data.error}</div>` : nothing
}
${
data.sessionsLimitReached
? html`
${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
}
: nothing}
</section>
${
isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
${isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
${renderUsageInsights(
displayTotals,
activeAggregates,
@@ -793,11 +770,9 @@ export function renderUsage(props: UsageProps) {
displayActions.onDailyChartModeChange,
filterActions.onSelectDay,
)}
${
displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing
}
${displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing}
</div>
${renderSessionsCard(
filteredSessions,
@@ -817,9 +792,8 @@ export function renderUsage(props: UsageProps) {
filterActions.onClearSessions,
)}
</div>
${
primarySelectedEntry
? html`<div class="usage-grid-column">
${primarySelectedEntry
? html`<div class="usage-grid-column">
${renderSessionDetailPanel(
primarySelectedEntry,
detail.timeSeries,
@@ -849,11 +823,9 @@ export function renderUsage(props: UsageProps) {
filterActions.onClearSessions,
)}
</div>`
: nothing
}
: nothing}
</div>
`
}
`}
</div>
`;
}