fix: restore green build after upstream API drift

This commit is contained in:
Peter Steinberger
2026-03-27 02:47:56 +00:00
parent bd6c7969ea
commit a331270f8a
53 changed files with 3369 additions and 2762 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", undefined, signal);
mockGenerateSummary(testMessages, testModel, 1000, "test-api-key", signal);
const runSummaryRetry = (options: Parameters<typeof retryAsync>[1]) =>
retryAsync(() => invokeGenerateSummary(), options);

View File

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

View File

@@ -614,18 +614,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
return { cancel: true };
}
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) {
log.warn(
`Compaction safeguard: failed to resolve auth; cancelling compaction to preserve history. ${auth.error}`,
);
setCompactionSafeguardCancelReason(
ctx.sessionManager,
`Compaction safeguard could not resolve request auth for ${model.provider}/${model.id}: ${auth.error}`,
);
return { cancel: true };
}
if (!auth.apiKey && !auth.headers) {
const apiKey = await ctx.modelRegistry.getApiKey(model);
if (!apiKey) {
log.warn(
"Compaction safeguard: no request auth available; cancelling compaction to preserve history.",
);
@@ -635,8 +625,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
);
return { cancel: true };
}
const apiKey = auth.apiKey ?? "";
const headers = auth.headers;
try {
const modelContextWindow = resolveContextWindowTokens(model);
@@ -700,7 +688,6 @@ 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,
@@ -772,7 +759,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: messagesToSummarize,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,
@@ -789,7 +775,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
messages: turnPrefixMessages,
model,
apiKey,
headers,
signal,
reserveTokens,
maxChunkTokens,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,13 +14,7 @@ function makeSkill(name: string, desc = "A skill", filePath = `/skills/${name}/S
description: desc,
filePath,
baseDir: `/skills/${name}`,
sourceInfo: {
path: filePath,
source: "workspace",
scope: "project",
origin: "top-level",
baseDir: `/skills/${name}`,
},
source: "workspace",
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.sourceInfo.source);
return BUNDLED_SOURCES.has(entry.skill.source);
}
export function resolveBundledAllowlist(config?: OpenClawConfig): string[] | undefined {

View File

@@ -38,13 +38,7 @@ describe("skills-cli (e2e)", () => {
description: "Capture UI screenshots",
filePath: path.join(baseDir, "SKILL.md"),
baseDir,
sourceInfo: {
path: path.join(baseDir, "SKILL.md"),
source: "openclaw-bundled",
scope: "project",
origin: "top-level",
baseDir,
},
source: "openclaw-bundled",
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.sourceInfo.source === "openclaw-bundled") {
if (entry.skill.source === "openclaw-bundled") {
continue;
}

View File

@@ -109,8 +109,9 @@ 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;
@@ -125,7 +126,8 @@ function renderCronFilterIcon(hiddenCount: number) {
"
>${hiddenCount}</span
>`
: ""}
: ""
}
</span>
`;
}
@@ -311,11 +313,13 @@ 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>
@@ -941,9 +945,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,9 +177,11 @@ 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>
@@ -695,70 +697,84 @@ 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,35 +81,49 @@ 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;
@keydown=${
canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") {
return;
}
e.preventDefault();
handleClick?.();
}
e.preventDefault();
handleClick?.();
}
: nothing}
: 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,7 +121,9 @@ 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" />
@@ -142,12 +144,8 @@ 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" />
@@ -238,7 +236,9 @@ 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,7 +286,9 @@ 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,13 +134,17 @@ 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,19 +179,24 @@ 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);
@@ -217,28 +222,33 @@ 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>
`;
@@ -289,26 +299,33 @@ 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"}">
@@ -333,7 +350,8 @@ export function renderAgentCron(params: {
`,
)}
</div>
`}
`
}
</section>
`;
}
@@ -376,46 +394,60 @@ 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>
@@ -450,13 +482,15 @@ 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
@@ -536,8 +570,10 @@ export function renderAgentFiles(params: {
</div>
</div>
</dialog>
`}
`}
`
}
`
}
</section>
`;
}

View File

@@ -200,41 +200,49 @@ 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">
@@ -245,14 +253,16 @@ 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;">
@@ -261,31 +271,32 @@ 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
? 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
${
!params.runtimeSessionMatchesSelectedAgent
? html`
<div class="callout info" style="margin-top: 12px">Loading available tools…</div>
<div class="callout info" style="margin-top: 12px">
Switch chat to this agent to view its live runtime tools.
</div>
`
: params.toolsEffectiveError
: params.toolsEffectiveLoading &&
!params.toolsEffectiveResult &&
!params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">
Could not load available tools for this session.
</div>
<div class="callout info" style="margin-top: 12px">Loading available tools…</div>
`
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
: params.toolsEffectiveError
? html`
<div class="callout info" style="margin-top: 12px">
No tools are available for this session right now.
Could not load available tools for this session.
</div>
`
: html`
: (params.toolsEffectiveResult?.groups?.length ?? 0) === 0
? html`
<div class="callout info" style="margin-top: 12px">
No tools are available for this session right now.
</div>
`
: html`
<div class="agent-tools-grid" style="margin-top: 16px;">
${params.toolsEffectiveResult?.groups.map(
(group) => html`
@@ -314,7 +325,8 @@ export function renderAgentTools(params: {
`,
)}
</div>
`}
`
}
</div>
<div class="agent-tools-presets" style="margin-top: 16px;">
@@ -347,11 +359,13 @@ 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) => {
@@ -430,9 +444,11 @@ 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;">
@@ -483,34 +499,40 @@ 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;">
@@ -526,9 +548,12 @@ 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, {
@@ -540,7 +565,8 @@ export function renderAgentSkills(params: {
}),
)}
</div>
`}
`
}
</section>
`;
}
@@ -596,12 +622,16 @@ 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,22 +154,29 @@ 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"
@@ -183,14 +190,17 @@ 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}
@@ -200,142 +210,158 @@ 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,
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,
${
props.activePanel === "overview"
? renderAgentOverview({
agent: selectedAgent,
basePath: props.basePath,
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}
`}
configForm: props.config.form,
agentFilesList: props.agentFiles.list,
agentIdentity: props.agentIdentityById[selectedAgent.id] ?? null,
agentIdentityError: props.agentIdentityError,
agentIdentityLoading: props.agentIdentityLoading,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
modelCatalog: props.modelCatalog,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
onModelChange: props.onModelChange,
onModelFallbacksChange: props.onModelFallbacksChange,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
${
props.activePanel === "files"
? renderAgentFiles({
agentId: selectedAgent.id,
agentFilesList: props.agentFiles.list,
agentFilesLoading: props.agentFiles.loading,
agentFilesError: props.agentFiles.error,
agentFileActive: props.agentFiles.active,
agentFileContents: props.agentFiles.contents,
agentFileDrafts: props.agentFiles.drafts,
agentFileSaving: props.agentFiles.saving,
onLoadFiles: props.onLoadFiles,
onSelectFile: props.onSelectFile,
onFileDraftChange: props.onFileDraftChange,
onFileReset: props.onFileReset,
onFileSave: props.onFileSave,
})
: nothing
}
${
props.activePanel === "tools"
? renderAgentTools({
agentId: selectedAgent.id,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
toolsCatalogLoading: props.toolsCatalog.loading,
toolsCatalogError: props.toolsCatalog.error,
toolsCatalogResult: props.toolsCatalog.result,
toolsEffectiveLoading: props.toolsEffective.loading,
toolsEffectiveError: props.toolsEffective.error,
toolsEffectiveResult: props.toolsEffective.result,
runtimeSessionKey: props.runtimeSessionKey,
runtimeSessionMatchesSelectedAgent: props.runtimeSessionMatchesSelectedAgent,
onProfileChange: props.onToolsProfileChange,
onOverridesChange: props.onToolsOverridesChange,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing
}
${
props.activePanel === "skills"
? renderAgentSkills({
agentId: selectedAgent.id,
report: props.agentSkills.report,
loading: props.agentSkills.loading,
error: props.agentSkills.error,
activeAgentId: props.agentSkills.agentId,
configForm: props.config.form,
configLoading: props.config.loading,
configSaving: props.config.saving,
configDirty: props.config.dirty,
filter: props.agentSkills.filter,
onFilterChange: props.onSkillsFilterChange,
onRefresh: props.onSkillsRefresh,
onToggle: props.onAgentSkillToggle,
onClear: props.onAgentSkillsClear,
onDisableAll: props.onAgentSkillsDisableAll,
onConfigReload: props.onConfigReload,
onConfigSave: props.onConfigSave,
})
: nothing
}
${
props.activePanel === "channels"
? renderAgentChannels({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
configForm: props.config.form,
snapshot: props.channels.snapshot,
loading: props.channels.loading,
error: props.channels.error,
lastSuccess: props.channels.lastSuccess,
onRefresh: props.onChannelsRefresh,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
${
props.activePanel === "cron"
? renderAgentCron({
context: buildAgentContext(
selectedAgent,
props.config.form,
props.agentFiles.list,
defaultId,
props.agentIdentityById[selectedAgent.id] ?? null,
),
agentId: selectedAgent.id,
jobs: props.cron.jobs,
status: props.cron.status,
loading: props.cron.loading,
error: props.cron.error,
onRefresh: props.onCronRefresh,
onRunNow: props.onCronRunNow,
onSelectPanel: props.onSelectPanel,
})
: nothing
}
`
}
</section>
</div>
`;
@@ -363,9 +389,11 @@ 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,11 +86,15 @@ 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);
@@ -116,16 +120,20 @@ 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,16 +108,20 @@ 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>
`;
}
@@ -140,16 +144,20 @@ 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>
`;
};
@@ -191,12 +199,16 @@ 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",
@@ -219,8 +231,9 @@ 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;"
>
@@ -248,7 +261,8 @@ export function renderNostrProfileForm(params: {
})}
</div>
`
: nothing}
: nothing
}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
@@ -274,13 +288,15 @@ 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,14 +80,16 @@ 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>
`;
@@ -128,8 +130,9 @@ 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}
@@ -138,13 +141,16 @@ export function renderNostrCard(params: {
Edit Profile
</button>
`
: nothing}
: nothing
}
</div>
${hasAnyProfileData
? html`
${
hasAnyProfileData
? html`
<div class="status-list">
${picture
? html`
${
picture
? html`
<div style="margin-bottom: 8px;">
<img
src=${picture}
@@ -156,33 +162,43 @@ 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>
`;
};
@@ -192,13 +208,14 @@ 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>
@@ -221,10 +238,13 @@ 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,9 +120,11 @@ 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,14 +41,16 @@ 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>
`;
@@ -65,15 +67,19 @@ 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,9 +82,11 @@ 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
@@ -198,13 +200,14 @@ 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>
@@ -219,10 +222,13 @@ 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>
`;
@@ -305,9 +311,11 @@ 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,14 +43,18 @@ 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,15 +642,17 @@ 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>
@@ -741,8 +743,9 @@ function renderPinnedSection(
>${icons.chevronDown}</span
>
</button>
${vs.pinnedExpanded
? html`
${
vs.pinnedExpanded
? html`
<div class="agent-chat__pinned-list">
${entries.map(
({ index, text, role }) => html`
@@ -768,7 +771,8 @@ function renderPinnedSection(
)}
</div>
`
: nothing}
: nothing
}
</div>
`;
}
@@ -801,9 +805,11 @@ 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>
@@ -845,9 +851,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)}
@@ -860,11 +866,15 @@ 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>
`,
)}
@@ -944,49 +954,46 @@ 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>
${
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>
</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>
</div>
</div>
</div>
`
: nothing}
`
: 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,
@@ -1164,8 +1171,9 @@ 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"
@@ -1176,7 +1184,8 @@ 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" : ""}">
@@ -1187,8 +1196,9 @@ export function renderChat(props: ChatProps) {
${thread}
</div>
${sidebarOpen
? html`
${
sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
@@ -1207,11 +1217,13 @@ 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">
@@ -1219,8 +1231,10 @@ 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"
@@ -1236,17 +1250,20 @@ 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">
@@ -1260,9 +1277,11 @@ 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))}
@@ -1290,12 +1309,13 @@ 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();
@@ -1342,15 +1362,17 @@ 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}
@@ -1359,7 +1381,8 @@ export function renderChat(props: ChatProps) {
>
${icons.plus}
</button>
`}
`
}
<button
class="btn btn--ghost"
@click=${() => exportMarkdown(props)}
@@ -1370,8 +1393,9 @@ 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}
@@ -1381,7 +1405,7 @@ export function renderChat(props: ChatProps) {
${icons.stop}
</button>
`
: html`
: html`
<button
class="chat-send-btn"
@click=${() => {
@@ -1396,7 +1420,8 @@ export function renderChat(props: ChatProps) {
>
${icons.send}
</button>
`}
`
}
</div>
</div>
</div>

View File

@@ -220,15 +220,16 @@ 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>
@@ -246,16 +247,19 @@ 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,9 +79,7 @@ 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`
@@ -153,16 +151,20 @@ 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)}
@@ -537,10 +539,9 @@ 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)}
>
@@ -693,8 +694,9 @@ function renderTextInput(params: {
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${schema.default !== undefined
? html`
${
schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
@@ -705,7 +707,8 @@ function renderTextInput(params: {
</button>
`
: nothing}
: nothing
}
</div>
</div>
`;
@@ -947,22 +950,24 @@ 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
@@ -1059,9 +1064,12 @@ 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`
@@ -1102,7 +1110,8 @@ function renderArray(params: {
`,
)}
</div>
`}
`
}
</div>
`;
}
@@ -1175,9 +1184,12 @@ 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];
@@ -1229,16 +1241,17 @@ 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}
@@ -1273,26 +1286,28 @@ 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,12 +360,16 @@ 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;
@@ -445,9 +449,11 @@ 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">
@@ -471,43 +477,45 @@ 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,11 +593,13 @@ 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>
`,
)}
@@ -644,14 +646,16 @@ 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>
@@ -786,8 +790,9 @@ 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" : ""}"
@@ -805,20 +810,28 @@ 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"}
@@ -827,7 +840,8 @@ 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>
@@ -844,8 +858,9 @@ 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
@@ -867,8 +882,9 @@ 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"
@@ -877,11 +893,13 @@ 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(
@@ -900,8 +918,9 @@ 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"
@@ -934,11 +953,13 @@ 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>
@@ -972,27 +993,32 @@ 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;
@@ -1015,75 +1041,83 @@ 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
? 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
${
props.schemaLoading
? 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 class="config-loading">
<div class="config-loading__spinner"></div>
<span>Loading schema…</span>
</div>
`
: nothing}
: 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
}
<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=${() => {
@@ -1094,16 +1128,18 @@ 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}
@@ -1111,17 +1147,21 @@ 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,12 +341,14 @@ 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>`;
}
@@ -400,11 +402,13 @@ 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>
@@ -543,15 +547,18 @@ 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"
@@ -562,7 +569,8 @@ export function renderCron(props: CronProps) {
</button>
</div>
`
: nothing}
: nothing
}
</section>
<section class="card">
@@ -573,9 +581,11 @@ 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">
@@ -666,21 +676,24 @@ 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"
@@ -691,7 +704,8 @@ export function renderCron(props: CronProps) {
</button>
</div>
`
: nothing}
: nothing
}
</section>
</div>
@@ -833,13 +847,16 @@ 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
@@ -864,7 +881,8 @@ export function renderCron(props: CronProps) {
)}
</label>
`
: nothing}
: nothing
}
</div>
<label class="field cron-span-2">
${renderFieldLabel(
@@ -905,16 +923,19 @@ 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"
@@ -922,8 +943,9 @@ 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}
@@ -941,7 +963,7 @@ export function renderCron(props: CronProps) {
placeholder=${t("cron.form.webhookPlaceholder")}
/>
`
: html`
: html`
<select
id="cron-delivery-channel"
.value=${props.form.deliveryChannel || "last"}
@@ -957,13 +979,17 @@ 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
@@ -979,15 +1005,19 @@ 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>
@@ -1032,8 +1062,9 @@ 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"
@@ -1087,9 +1118,11 @@ export function renderCron(props: CronProps) {
</label>
</div>
`
: nothing}
${isAgentTurn
? html`
: nothing
}
${
isAgentTurn
? html`
<label class="field cron-span-2">
${renderFieldLabel("Account ID")}
<input
@@ -1150,9 +1183,11 @@ 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
@@ -1171,8 +1206,9 @@ 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
@@ -1201,9 +1237,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")
@@ -1279,11 +1315,14 @@ 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"
@@ -1299,12 +1338,14 @@ 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>
@@ -1325,29 +1366,36 @@ 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>
@@ -1474,11 +1522,13 @@ 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">
@@ -1589,12 +1639,14 @@ 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
}
`;
}
@@ -1718,15 +1770,20 @@ 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}
@@ -1749,7 +1806,8 @@ 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,12 +48,14 @@ 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>
@@ -78,9 +80,13 @@ 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>
@@ -97,12 +103,16 @@ 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>
@@ -117,9 +127,12 @@ ${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`
@@ -137,7 +150,8 @@ ${formatEventPayload(evt.payload)}</pre
`,
)}
</div>
`}
`
}
</section>
`;
}

View File

@@ -39,9 +39,11 @@ 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">
@@ -50,9 +52,11 @@ 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,16 +42,24 @@ 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>
`;
@@ -86,9 +94,11 @@ 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,11 +99,13 @@ 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,25 +114,32 @@ 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>
@@ -140,7 +147,8 @@ export function renderLogs(props: LogsProps) {
<div class="log-message mono">${entry.message ?? entry.raw}</div>
</div>
`,
)}
)
}
</div>
</section>
`;

View File

@@ -18,18 +18,22 @@ 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,19 +211,23 @@ 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>
`;
}
@@ -258,8 +262,9 @@ 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
@@ -280,12 +285,17 @@ 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>
`;
}
@@ -296,9 +306,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
@@ -359,11 +369,13 @@ 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}>
@@ -397,11 +409,13 @@ 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}>
@@ -417,9 +431,11 @@ 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">
@@ -437,11 +453,13 @@ 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}>
@@ -457,11 +475,13 @@ 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">
@@ -477,15 +497,17 @@ 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>
@@ -513,9 +535,13 @@ 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,9 +62,13 @@ 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>
`;
@@ -85,25 +89,35 @@ 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>
`;
@@ -151,14 +165,18 @@ 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>
`;
@@ -180,16 +198,18 @@ 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>
`;
@@ -265,21 +285,24 @@ 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">
@@ -306,17 +329,26 @@ 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>
`;
}
@@ -331,9 +363,11 @@ 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,15 +42,17 @@ 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,8 +136,9 @@ 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">
@@ -157,6 +158,7 @@ export function renderOverviewCards(props: OverviewCardsProps) {
</ul>
</section>
`
: nothing}
: nothing
}
`;
}

View File

@@ -28,11 +28,13 @@ 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,9 +215,10 @@ 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;">
@@ -272,7 +273,8 @@ export function renderOverview(props: OverviewProps) {
</button>
</div>
</label>
`}
`
}
<label class="field">
<span>${t("overview.access.sessionKey")}</span>
<input
@@ -306,13 +308,14 @@ 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">
@@ -339,7 +342,8 @@ export function renderOverview(props: OverviewProps) {
</div>
</div>
`
: nothing}
: nothing
}
</div>
<div class="card">
@@ -363,22 +367,26 @@ 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,9 +216,11 @@ 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}>
@@ -286,9 +288,11 @@ 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">
@@ -302,8 +306,9 @@ 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>
@@ -316,20 +321,26 @@ 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) {
@@ -340,7 +351,8 @@ export function renderSessions(props: SessionsProps) {
}}
aria-label="Select all on page"
/>`
: nothing}
: nothing
}
</th>
${sortHeader("key", "Key", "data-table-key-col")}
<th>Label</th>
@@ -353,34 +365,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)}
@@ -407,7 +419,8 @@ export function renderSessions(props: SessionsProps) {
</div>
</div>
`
: nothing}
: nothing
}
</div>
</section>
`;
@@ -467,8 +480,9 @@ 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) => {
@@ -489,10 +503,13 @@ 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,11 +30,23 @@ 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,18 +159,21 @@ 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`
@@ -186,7 +189,8 @@ export function renderSkills(props: SkillsProps) {
`;
})}
</div>
`}
`
}
</section>
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing}
@@ -267,8 +271,9 @@ 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);"
@@ -277,12 +282,15 @@ 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">
@@ -297,24 +305,29 @@ 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
@@ -350,7 +363,8 @@ 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,11 +114,13 @@ 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>
@@ -143,8 +145,10 @@ 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)}
@@ -271,14 +275,17 @@ 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",
@@ -286,7 +293,8 @@ function renderSessionDetailPanel(
>
<span><strong>${formatCost(headerStats.totalCost)}</strong>${cursorIndicator}</span>
`
: nothing}
: nothing
}
</div>
<button
class="btn btn--sm btn--ghost"
@@ -480,8 +488,9 @@ 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"
@@ -491,7 +500,8 @@ function renderTimeSeriesCompact(
</button>
</div>
`
: nothing}
: nothing
}
<div class="chart-toggle small">
<button
class="btn btn--sm toggle-btn ${!isCumulative ? "active" : ""}"
@@ -506,8 +516,9 @@ 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" : ""}"
@@ -523,7 +534,8 @@ function renderTimeSeriesCompact(
</button>
</div>
`
: nothing}
: nothing
}
</div>
</div>
<div class="timeseries-chart-wrapper">
@@ -562,12 +574,14 @@ 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];
@@ -721,8 +735,9 @@ function renderTimeSeriesCompact(
})()}
</div>
<div class="timeseries-summary">
${hasSelection
? html`
${
hasSelection
? html`
<span class="timeseries-summary__range">
${t("usage.details.turnRange", {
start: String(rangeStartIdx + 1),
@@ -744,11 +759,13 @@ 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">
@@ -792,7 +809,8 @@ function renderTimeSeriesCompact(
</div>
</div>
`
: nothing}
: nothing
}
</div>
`;
}
@@ -851,11 +869,13 @@ 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">
@@ -902,10 +922,11 @@ 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})
@@ -920,21 +941,25 @@ 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})
@@ -951,21 +976,25 @@ 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})
@@ -982,17 +1011,20 @@ 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>
`;
@@ -1177,8 +1209,9 @@ 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">
@@ -1190,17 +1223,20 @@ 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,8 +93,9 @@ 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
@@ -107,9 +108,11 @@ 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
@@ -122,9 +125,11 @@ 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
@@ -137,14 +142,17 @@ 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>
`;
}
@@ -259,8 +267,9 @@ 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;"
@@ -278,16 +287,19 @@ 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>
`;
@@ -318,34 +330,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">
@@ -359,15 +371,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">
@@ -386,9 +398,10 @@ 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`
@@ -402,7 +415,8 @@ function renderInsightList(
`,
)}
</div>
`}
`
}
</div>
`;
}
@@ -421,9 +435,10 @@ 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`
@@ -435,7 +450,8 @@ function renderPeakErrorList(
`,
)}
</div>
`}
`
}
</div>
`;
}
@@ -800,9 +816,11 @@ 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
@@ -837,9 +855,11 @@ 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">
@@ -890,46 +910,55 @@ 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) })}
@@ -939,7 +968,8 @@ function renderSessionsCard(
</div>
</div>
`
: nothing}
: nothing
}
</div>
`;
}

View File

@@ -376,9 +376,11 @@ 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">
@@ -447,16 +449,21 @@ 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")}
@@ -467,12 +474,15 @@ 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")}
@@ -654,20 +664,24 @@ 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>
@@ -679,8 +693,9 @@ 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;
@@ -701,9 +716,11 @@ 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`
@@ -720,29 +737,35 @@ 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,
@@ -770,9 +793,11 @@ export function renderUsage(props: UsageProps) {
displayActions.onDailyChartModeChange,
filterActions.onSelectDay,
)}
${displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing}
${
displayTotals
? renderCostBreakdownCompact(displayTotals, display.chartMode)
: nothing
}
</div>
${renderSessionsCard(
filteredSessions,
@@ -792,8 +817,9 @@ 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,
@@ -823,9 +849,11 @@ export function renderUsage(props: UsageProps) {
filterActions.onClearSessions,
)}
</div>`
: nothing}
: nothing
}
</div>
`}
`
}
</div>
`;
}