chore: Run pnpm format:fix.

This commit is contained in:
cpojer
2026-01-31 21:13:13 +09:00
parent dcc2de15a6
commit 8cab78abbc
624 changed files with 10729 additions and 7514 deletions
+26 -10
View File
@@ -81,9 +81,11 @@
--theme-switch-y: 50%;
/* Typography - Space Grotesk for personality */
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display:
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
@@ -340,7 +342,8 @@ select {
}
@keyframes pulse-subtle {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
@@ -349,7 +352,8 @@ select {
}
@keyframes glow-pulse {
0%, 100% {
0%,
100% {
box-shadow: 0 0 0 rgba(255, 92, 92, 0);
}
50% {
@@ -358,12 +362,24 @@ select {
}
/* Stagger animation delays for grouped elements */
.stagger-1 { animation-delay: 0ms; }
.stagger-2 { animation-delay: 50ms; }
.stagger-3 { animation-delay: 100ms; }
.stagger-4 { animation-delay: 150ms; }
.stagger-5 { animation-delay: 200ms; }
.stagger-6 { animation-delay: 250ms; }
.stagger-1 {
animation-delay: 0ms;
}
.stagger-2 {
animation-delay: 50ms;
}
.stagger-3 {
animation-delay: 100ms;
}
.stagger-4 {
animation-delay: 150ms;
}
.stagger-5 {
animation-delay: 200ms;
}
.stagger-6 {
animation-delay: 250ms;
}
/* Focus visible styles */
:focus-visible {
+8 -3
View File
@@ -105,7 +105,9 @@ img.chat-avatar {
border-radius: var(--radius-lg);
padding: 10px 14px;
box-shadow: none;
transition: background 150ms ease-out, border-color 150ms ease-out;
transition:
background 150ms ease-out,
border-color 150ms ease-out;
max-width: 100%;
word-wrap: break-word;
}
@@ -128,7 +130,9 @@ img.chat-avatar {
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, background 120ms ease-out;
transition:
opacity 120ms ease-out,
background 120ms ease-out;
}
.chat-copy-btn__icon {
@@ -243,7 +247,8 @@ img.chat-avatar {
}
@keyframes pulsing-border {
0%, 100% {
0%,
100% {
border-color: var(--border);
}
50% {
+4 -1
View File
@@ -77,7 +77,10 @@
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease-out, color 150ms ease-out, border-color 150ms ease-out;
transition:
background 150ms ease-out,
color 150ms ease-out,
border-color 150ms ease-out;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
+6 -2
View File
@@ -6,7 +6,9 @@
margin-top: 8px;
background: var(--card);
box-shadow: inset 0 1px 0 var(--card-highlight);
transition: border-color 150ms ease-out, background 150ms ease-out;
transition:
border-color 150ms ease-out,
background 150ms ease-out;
/* Fixed max-height to ensure cards don't expand too much */
max-height: 120px;
overflow: hidden;
@@ -187,7 +189,9 @@
}
@keyframes reading-pulse {
0%, 60%, 100% {
0%,
60%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
+20 -8
View File
@@ -1,4 +1,4 @@
@import './chat.css';
@import "./chat.css";
/* ===========================================
Cards - Refined with depth
@@ -14,12 +14,16 @@
border-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
box-shadow:
var(--shadow-sm),
inset 0 1px 0 var(--card-highlight);
}
.card:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-md), inset 0 1px 0 var(--card-highlight);
box-shadow:
var(--shadow-md),
inset 0 1px 0 var(--card-highlight);
}
.card-title {
@@ -53,7 +57,9 @@
.stat:hover {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm), inset 0 1px 0 var(--card-highlight);
box-shadow:
var(--shadow-sm),
inset 0 1px 0 var(--card-highlight);
}
.stat-label {
@@ -351,7 +357,9 @@
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: var(--shadow-md), 0 0 20px var(--accent-glow);
box-shadow:
var(--shadow-md),
0 0 20px var(--accent-glow);
}
/* Keyboard shortcut badge (shadcn style) */
@@ -571,7 +579,8 @@
}
@keyframes compaction-pulse {
0%, 100% {
0%,
100% {
opacity: 0.7;
}
50% {
@@ -1050,7 +1059,8 @@
}
@keyframes chatStreamPulse {
0%, 100% {
0%,
100% {
border-color: var(--border);
}
50% {
@@ -1103,7 +1113,9 @@
}
@keyframes chatReadingDot {
0%, 80%, 100% {
0%,
80%,
100% {
opacity: 0.4;
transform: translateY(0);
}
+13 -6
View File
@@ -122,9 +122,12 @@ export async function handleNostrProfileSave(host: OpenClawApp) {
},
body: JSON.stringify(state.values),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; details?: unknown; persisted?: boolean }
| null;
const data = (await response.json().catch(() => null)) as {
ok?: boolean;
error?: string;
details?: unknown;
persisted?: boolean;
} | null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile update failed (${response.status})`;
@@ -187,9 +190,13 @@ export async function handleNostrProfileImport(host: OpenClawApp) {
},
body: JSON.stringify({ autoMerge: true }),
});
const data = (await response.json().catch(() => null)) as
| { ok?: boolean; error?: string; imported?: NostrProfile; merged?: NostrProfile; saved?: boolean }
| null;
const data = (await response.json().catch(() => null)) as {
ok?: boolean;
error?: string;
imported?: NostrProfile;
merged?: NostrProfile;
saved?: boolean;
} | null;
if (!response.ok || data?.ok === false || !data) {
const errorMessage = data?.error ?? `Profile import failed (${response.status})`;
+7 -2
View File
@@ -101,7 +101,10 @@ async function sendChatMessageNow(
host.chatAttachments = opts.previousAttachments;
}
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters<typeof setLastActiveSessionKey>[0], host.sessionKey);
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
@@ -199,7 +202,9 @@ type SessionDefaultsSnapshot = {
function resolveAgentIdForSession(host: ChatHost): string | null {
const parsed = parseAgentSessionKey(host.sessionKey);
if (parsed?.agentId) return parsed.agentId;
const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined;
const snapshot = host.hello?.snapshot as
| { sessionDefaults?: SessionDefaultsSnapshot }
| undefined;
const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim();
return fallback || "main";
}
+3 -11
View File
@@ -10,12 +10,7 @@ import type { Tab } from "./navigation";
import type { UiSettings } from "./storage";
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat";
import {
applySettings,
loadCron,
refreshActiveTab,
setLastActiveSessionKey,
} from "./app-settings";
import { applySettings, loadCron, refreshActiveTab, setLastActiveSessionKey } from "./app-settings";
import { handleChatEvent, type ChatEventPayload } from "./controllers/chat";
import {
addExecApproval,
@@ -77,8 +72,7 @@ function normalizeSessionKeyForDefaults(
raw === "main" ||
raw === mainKey ||
(defaultAgentId &&
(raw === `agent:${defaultAgentId}:main` ||
raw === `agent:${defaultAgentId}:${mainKey}`));
(raw === `agent:${defaultAgentId}:main` || raw === `agent:${defaultAgentId}:${mainKey}`));
return isAlias ? mainSessionKey : raw;
}
@@ -193,9 +187,7 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
const state = handleChatEvent(host as unknown as OpenClawApp, payload);
if (state === "final" || state === "error" || state === "aborted") {
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void flushChatQueueForEvent(
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
);
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
const runId = payload?.runId;
if (runId && host.refreshSessionsAfterChat.has(runId)) {
host.refreshSessionsAfterChat.delete(runId);
+6 -20
View File
@@ -35,19 +35,10 @@ type LifecycleHost = {
export function handleConnected(host: LifecycleHost) {
host.basePath = inferBasePath();
applySettingsFromUrl(
host as unknown as Parameters<typeof applySettingsFromUrl>[0],
);
syncTabWithLocation(
host as unknown as Parameters<typeof syncTabWithLocation>[0],
true,
);
syncThemeWithSettings(
host as unknown as Parameters<typeof syncThemeWithSettings>[0],
);
attachThemeListener(
host as unknown as Parameters<typeof attachThemeListener>[0],
);
applySettingsFromUrl(host as unknown as Parameters<typeof applySettingsFromUrl>[0]);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler);
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
@@ -68,17 +59,12 @@ export function handleDisconnected(host: LifecycleHost) {
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
detachThemeListener(
host as unknown as Parameters<typeof detachThemeListener>[0],
);
detachThemeListener(host as unknown as Parameters<typeof detachThemeListener>[0]);
host.topbarObserver?.disconnect();
host.topbarObserver = null;
}
export function handleUpdated(
host: LifecycleHost,
changed: Map<PropertyKey, unknown>,
) {
export function handleUpdated(host: LifecycleHost, changed: Map<PropertyKey, unknown>) {
if (
host.tab === "chat" &&
(changed.has("chatMessages") ||
+45 -14
View File
@@ -51,8 +51,39 @@ export function renderChatControls(state: AppViewState) {
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
const refreshIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
`;
const focusIcon = html`
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 7V4h3"></path>
<path d="M20 7V4h-3"></path>
<path d="M4 17v3h3"></path>
<path d="M20 17v3h-3"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
return html`
<div class="chat-controls">
<label class="field chat-controls__session">
@@ -111,9 +142,11 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${showThinking}
title=${disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"}
title=${
disableThinkingToggle
? "Disabled during onboarding"
: "Toggle assistant thinking/working output"
}
>
${icons.brain}
</button>
@@ -128,9 +161,11 @@ export function renderChatControls(state: AppViewState) {
});
}}
aria-pressed=${focusActive}
title=${disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"}
title=${
disableFocusToggle
? "Disabled during onboarding"
: "Toggle focus mode (hide sidebar + page header)"
}
>
${focusIcon}
</button>
@@ -156,10 +191,7 @@ function resolveMainSessionKey(
return null;
}
function resolveSessionDisplayName(
key: string,
row?: SessionsListResult["sessions"][number],
) {
function resolveSessionDisplayName(key: string, row?: SessionsListResult["sessions"][number]) {
const label = row?.label?.trim();
if (label) return `${label} (${key})`;
const displayName = row?.displayName?.trim();
@@ -175,8 +207,7 @@ function resolveSessionOptions(
const seen = new Set<string>();
const options: Array<{ key: string; displayName?: string }> = [];
const resolvedMain =
mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedMain = mainSessionKey && sessions?.sessions?.find((s) => s.key === mainSessionKey);
const resolvedCurrent = sessions?.sessions?.find((s) => s.key === sessionKey);
// Add main session key first
+391 -369
View File
@@ -79,7 +79,13 @@ import {
saveExecApprovals,
updateExecApprovalsFormValue,
} from "./controllers/exec-approvals";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import {
loadCronRuns,
toggleCronJob,
runCronJob,
removeCronJob,
addCronJob,
} from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";
import { loadLogs } from "./controllers/logs";
@@ -89,10 +95,7 @@ const AVATAR_HTTP_RE = /^https?:\/\//i;
function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
const list = state.agentsList?.agents ?? [];
const parsed = parseAgentSessionKey(state.sessionKey);
const agentId =
parsed?.agentId ??
state.agentsList?.defaultId ??
"main";
const agentId = parsed?.agentId ?? state.agentsList?.defaultId ?? "main";
const agent = list.find((entry) => entry.id === agentId);
const identity = agent?.identity;
const candidate = identity?.avatarUrl ?? identity?.avatar;
@@ -199,384 +202,403 @@ export function renderApp(state: AppViewState) {
<div class="page-sub">${subtitleForTab(state.tab)}</div>
</div>
<div class="page-meta">
${state.lastError
? html`<div class="pill danger">${state.lastError}</div>`
: nothing}
${state.lastError ? html`<div class="pill danger">${state.lastError}</div>` : nothing}
${isChat ? renderChatControls(state) : nothing}
</div>
</section>
${state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing}
${
state.tab === "overview"
? renderOverview({
connected: state.connected,
hello: state.hello,
settings: state.settings,
password: state.password,
lastError: state.lastError,
presenceCount,
sessionsCount,
cronEnabled: state.cronStatus?.enabled ?? null,
cronNext,
lastChannelsRefresh: state.channelsLastSuccess,
onSettingsChange: (next) => state.applySettings(next),
onPasswordChange: (next) => (state.password = next),
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.resetToolStream();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview(),
})
: nothing
}
${state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing}
${
state.tab === "channels"
? renderChannels({
connected: state.connected,
loading: state.channelsLoading,
snapshot: state.channelsSnapshot,
lastError: state.channelsError,
lastSuccessAt: state.channelsLastSuccess,
whatsappMessage: state.whatsappLoginMessage,
whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
whatsappConnected: state.whatsappLoginConnected,
whatsappBusy: state.whatsappBusy,
configSchema: state.configSchema,
configSchemaLoading: state.configSchemaLoading,
configForm: state.configForm,
configUiHints: state.configUiHints,
configSaving: state.configSaving,
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
})
: nothing
}
${state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing}
${
state.tab === "instances"
? renderInstances({
loading: state.presenceLoading,
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
})
: nothing
}
${state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing}
${
state.tab === "sessions"
? renderSessions({
loading: state.sessionsLoading,
result: state.sessionsResult,
error: state.sessionsError,
activeMinutes: state.sessionsFilterActive,
limit: state.sessionsFilterLimit,
includeGlobal: state.sessionsIncludeGlobal,
includeUnknown: state.sessionsIncludeUnknown,
basePath: state.basePath,
onFiltersChange: (next) => {
state.sessionsFilterActive = next.activeMinutes;
state.sessionsFilterLimit = next.limit;
state.sessionsIncludeGlobal = next.includeGlobal;
state.sessionsIncludeUnknown = next.includeUnknown;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onDelete: (key) => deleteSession(state, key),
})
: nothing
}
${state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: state.channelsSnapshot?.channelOrder ?? [],
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing}
${
state.tab === "cron"
? renderCron({
loading: state.cronLoading,
status: state.cronStatus,
jobs: state.cronJobs,
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: (state.channelsSnapshot?.channelOrder ?? []),
channelLabels: state.channelsSnapshot?.channelLabels ?? {},
channelMeta: state.channelsSnapshot?.channelMeta ?? [],
runsJobId: state.cronRunsJobId,
runs: state.cronRuns,
onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job) => runCronJob(state, job),
onRemove: (job) => removeCronJob(state, job),
onLoadRuns: (jobId) => loadCronRuns(state, jobId),
})
: nothing
}
${state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing}
${
state.tab === "skills"
? renderSkills({
loading: state.skillsLoading,
report: state.skillsReport,
error: state.skillsError,
filter: state.skillsFilter,
edits: state.skillEdits,
messages: state.skillMessages,
busyKey: state.skillsBusyKey,
onFilterChange: (next) => (state.skillsFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
})
: nothing
}
${state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}
${
state.tab === "nodes"
? renderNodes({
loading: state.nodesLoading,
nodes: state.nodes,
devicesLoading: state.devicesLoading,
devicesError: state.devicesError,
devicesList: state.devicesList,
configForm:
state.configForm ??
(state.configSnapshot?.config as Record<string, unknown> | null),
configLoading: state.configLoading,
configSaving: state.configSaving,
configDirty: state.configFormDirty,
configFormMode: state.configFormMode,
execApprovalsLoading: state.execApprovalsLoading,
execApprovalsSaving: state.execApprovalsSaving,
execApprovalsDirty: state.execApprovalsDirty,
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
} else {
removeConfigFormValue(state, ["tools", "exec", "node"]);
}
},
onBindAgent: (agentIndex, nodeId) => {
const basePath = ["agents", "list", agentIndex, "tools", "exec", "node"];
if (nodeId) {
updateConfigFormValue(state, basePath, nodeId);
} else {
removeConfigFormValue(state, basePath);
}
},
onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId;
},
onExecApprovalsPatch: (path, value) =>
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing
}
${state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () =>
state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing}
${
state.tab === "chat"
? renderChat({
sessionKey: state.sessionKey,
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
state.chatQueue = [];
state.resetToolStream();
state.resetChatScroll();
state.applySettings({
...state.settings,
sessionKey: next,
lastActiveSessionKey: next,
});
void state.loadAssistantIdentity();
void loadChatHistory(state);
void refreshChatAvatar(state);
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,
assistantAvatarUrl: chatAvatarUrl,
messages: state.chatMessages,
toolMessages: state.chatToolMessages,
stream: state.chatStream,
streamStartedAt: state.chatStreamStartedAt,
draft: state.chatMessage,
queue: state.chatQueue,
connected: state.connected,
canSend: state.connected,
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
focusMode: chatFocus,
onRefresh: () => {
state.resetToolStream();
return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]);
},
onToggleFocusMode: () => {
if (state.onboarding) return;
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
onQueueRemove: (id) => state.removeQueuedMessage(id),
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
// Sidebar props for tool output viewing
sidebarOpen: state.sidebarOpen,
sidebarContent: state.sidebarContent,
sidebarError: state.sidebarError,
splitRatio: state.splitRatio,
onOpenSidebar: (content: string) => state.handleOpenSidebar(content),
onCloseSidebar: () => state.handleCloseSidebar(),
onSplitRatioChange: (ratio: number) => state.handleSplitRatioChange(ratio),
assistantName: state.assistantName,
assistantAvatar: state.assistantAvatar,
})
: nothing
}
${state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing}
${
state.tab === "config"
? renderConfig({
raw: state.configRaw,
originalRaw: state.configRawOriginal,
valid: state.configValid,
issues: state.configIssues,
loading: state.configLoading,
saving: state.configSaving,
applying: state.configApplying,
updating: state.updateRunning,
connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
originalValue: state.configFormOriginal,
searchQuery: state.configSearchQuery,
activeSection: state.configActiveSection,
activeSubsection: state.configActiveSubsection,
onRawChange: (next) => {
state.configRaw = next;
},
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onSearchChange: (query) => (state.configSearchQuery = query),
onSectionChange: (section) => {
state.configActiveSection = section;
state.configActiveSubsection = null;
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
onReload: () => loadConfig(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
})
: nothing
}
${state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing}
${
state.tab === "debug"
? renderDebug({
loading: state.debugLoading,
status: state.debugStatus,
health: state.debugHealth,
models: state.debugModels,
heartbeat: state.debugHeartbeat,
eventLog: state.eventLog,
callMethod: state.debugCallMethod,
callParams: state.debugCallParams,
callResult: state.debugCallResult,
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
})
: nothing
}
${state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing}
${
state.tab === "logs"
? renderLogs({
loading: state.logsLoading,
error: state.logsError,
file: state.logsFile,
entries: state.logsEntries,
filterText: state.logsFilterText,
levelFilters: state.logsLevelFilters,
autoFollow: state.logsAutoFollow,
truncated: state.logsTruncated,
onFilterTextChange: (next) => (state.logsFilterText = next),
onLevelToggle: (level, enabled) => {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
})
: nothing
}
</main>
${renderExecApprovalPrompt(state)}
${renderGatewayUrlConfirmation(state)}
+4 -8
View File
@@ -35,8 +35,7 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
host.chatScrollFrame = null;
const target = pickScrollTarget();
if (!target) return;
const distanceFromBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
const shouldStick = force || host.chatUserNearBottom || distanceFromBottom < 200;
if (!shouldStick) return;
if (force) host.chatHasAutoScrolled = true;
@@ -49,8 +48,7 @@ export function scheduleChatScroll(host: ScrollHost, force = false) {
if (!latest) return;
const latestDistanceFromBottom =
latest.scrollHeight - latest.scrollTop - latest.clientHeight;
const shouldStickRetry =
force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
const shouldStickRetry = force || host.chatUserNearBottom || latestDistanceFromBottom < 200;
if (!shouldStickRetry) return;
latest.scrollTop = latest.scrollHeight;
host.chatUserNearBottom = true;
@@ -78,16 +76,14 @@ export function scheduleLogsScroll(host: ScrollHost, force = false) {
export function handleChatScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.chatUserNearBottom = distanceFromBottom < 200;
}
export function handleLogsScroll(host: ScrollHost, event: Event) {
const container = event.currentTarget as HTMLElement | null;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.scrollTop - container.clientHeight;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
host.logsAtBottom = distanceFromBottom < 80;
}
+19 -20
View File
@@ -9,12 +9,24 @@ import { loadExecApprovals } from "./controllers/exec-approvals";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills";
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import {
inferBasePathFromPathname,
normalizeBasePath,
normalizePath,
pathForTab,
tabFromPath,
type Tab,
} from "./navigation";
import { saveSettings, type UiSettings } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
import {
startLogsPolling,
stopLogsPolling,
startDebugPolling,
stopDebugPolling,
} from "./app-polling";
import { refreshChat } from "./app-chat";
import type { OpenClawApp } from "./app";
@@ -114,8 +126,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
export function setTab(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
if (next === "logs") startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
@@ -124,11 +135,7 @@ export function setTab(host: SettingsHost, next: Tab) {
syncUrlWithTab(host, next, false);
}
export function setTheme(
host: SettingsHost,
next: ThemeMode,
context?: ThemeTransitionContext,
) {
export function setTheme(host: SettingsHost, next: ThemeMode, context?: ThemeTransitionContext) {
const applyTheme = () => {
host.theme = next;
applySettings(host, { ...host.settings, theme: next });
@@ -173,10 +180,7 @@ export async function refreshActiveTab(host: SettingsHost) {
if (host.tab === "logs") {
host.logsAtBottom = true;
await loadLogs(host as unknown as OpenClawApp, { reset: true });
scheduleLogsScroll(
host as unknown as Parameters<typeof scheduleLogsScroll>[0],
true,
);
scheduleLogsScroll(host as unknown as Parameters<typeof scheduleLogsScroll>[0], true);
}
}
@@ -262,8 +266,7 @@ export function onPopState(host: SettingsHost) {
export function setTabFromRoute(host: SettingsHost, next: Tab) {
if (host.tab !== next) host.tab = next;
if (next === "chat") host.chatHasAutoScrolled = false;
if (next === "logs")
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
if (next === "logs") startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
if (next === "debug")
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
@@ -294,11 +297,7 @@ export function syncUrlWithTab(host: SettingsHost, tab: Tab, replace: boolean) {
}
}
export function syncUrlWithSessionKey(
host: SettingsHost,
sessionKey: string,
replace: boolean,
) {
export function syncUrlWithSessionKey(host: SettingsHost, sessionKey: string, replace: boolean) {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
url.searchParams.set("session", sessionKey);
+1 -2
View File
@@ -191,8 +191,7 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
}
if (payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
if (sessionKey && sessionKey !== host.sessionKey) return;
// Fallback: only accept session-less events for the active run.
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
+1 -4
View File
@@ -22,10 +22,7 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form";
+15 -42
View File
@@ -27,10 +27,7 @@ import type {
import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
ExecApprovalsFile,
ExecApprovalsSnapshot,
} from "./controllers/exec-approvals";
import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals";
import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
@@ -261,9 +258,7 @@ export class OpenClawApp extends LitElement {
refreshSessionsAfterChat = new Set<string>();
basePath = "";
private popStateHandler = () =>
onPopStateInternal(
this as unknown as Parameters<typeof onPopStateInternal>[0],
);
onPopStateInternal(this as unknown as Parameters<typeof onPopStateInternal>[0]);
private themeMedia: MediaQueryList | null = null;
private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
private topbarObserver: ResizeObserver | null = null;
@@ -287,16 +282,11 @@ export class OpenClawApp extends LitElement {
}
protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(
this as unknown as Parameters<typeof handleUpdated>[0],
changed,
);
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
}
connect() {
connectGatewayInternal(
this as unknown as Parameters<typeof connectGatewayInternal>[0],
);
connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
}
handleChatScroll(event: Event) {
@@ -318,15 +308,11 @@ export class OpenClawApp extends LitElement {
}
resetToolStream() {
resetToolStreamInternal(
this as unknown as Parameters<typeof resetToolStreamInternal>[0],
);
resetToolStreamInternal(this as unknown as Parameters<typeof resetToolStreamInternal>[0]);
}
resetChatScroll() {
resetChatScrollInternal(
this as unknown as Parameters<typeof resetChatScrollInternal>[0],
);
resetChatScrollInternal(this as unknown as Parameters<typeof resetChatScrollInternal>[0]);
}
async loadAssistantIdentity() {
@@ -334,10 +320,7 @@ export class OpenClawApp extends LitElement {
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
next,
);
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], next);
}
setTab(next: Tab) {
@@ -345,29 +328,19 @@ export class OpenClawApp extends LitElement {
}
setTheme(next: ThemeMode, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(
this as unknown as Parameters<typeof setThemeInternal>[0],
next,
context,
);
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
}
async loadOverview() {
await loadOverviewInternal(
this as unknown as Parameters<typeof loadOverviewInternal>[0],
);
await loadOverviewInternal(this as unknown as Parameters<typeof loadOverviewInternal>[0]);
}
async loadCron() {
await loadCronInternal(
this as unknown as Parameters<typeof loadCronInternal>[0],
);
await loadCronInternal(this as unknown as Parameters<typeof loadCronInternal>[0]);
}
async handleAbortChat() {
await handleAbortChatInternal(
this as unknown as Parameters<typeof handleAbortChatInternal>[0],
);
await handleAbortChatInternal(this as unknown as Parameters<typeof handleAbortChatInternal>[0]);
}
removeQueuedMessage(id: string) {
@@ -454,10 +427,10 @@ export class OpenClawApp extends LitElement {
const nextGatewayUrl = this.pendingGatewayUrl;
if (!nextGatewayUrl) return;
this.pendingGatewayUrl = null;
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],
{ ...this.settings, gatewayUrl: nextGatewayUrl },
);
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
...this.settings,
gatewayUrl: nextGatewayUrl,
});
this.connect();
}
+2 -5
View File
@@ -28,13 +28,10 @@ function coerceIdentityValue(value: string | undefined, maxLength: number): stri
export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null,
): AssistantIdentity {
const name =
coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
const agentId =
typeof input?.agentId === "string" && input.agentId.trim()
? input.agentId.trim()
: null;
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
return { agentId, name, avatar };
}
+1 -3
View File
@@ -46,9 +46,7 @@ describe("chat markdown rendering", () => {
await app.updateComplete;
const toolCards = Array.from(
app.querySelectorAll<HTMLElement>(".chat-tool-card"),
);
const toolCards = Array.from(app.querySelectorAll<HTMLElement>(".chat-tool-card"));
const toolCard = toolCards.find((card) =>
card.querySelector(".chat-tool-card__preview, .chat-tool-card__inline"),
);
+1 -3
View File
@@ -38,9 +38,7 @@ function createCopyButton(options: CopyButtonOptions): TemplateResult {
aria-label=${idleLabel}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
const iconContainer = btn?.querySelector(
".chat-copy-btn__icon",
) as HTMLElement | null;
const iconContainer = btn?.querySelector(".chat-copy-btn__icon") as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") return;
+21 -35
View File
@@ -35,9 +35,7 @@ function extractImages(message: unknown): ImageBlock[] {
const data = source.data as string;
const mediaType = (source.media_type as string) || "image/png";
// If data is already a data URL, use it directly
const url = data.startsWith("data:")
? data
: `data:${mediaType};base64,${data}`;
const url = data.startsWith("data:") ? data : `data:${mediaType};base64,${data}`;
images.push({ url });
} else if (typeof b.url === "string") {
images.push({ url: b.url });
@@ -122,11 +120,7 @@ export function renderMessageGroup(
? assistantName
: normalizedRole;
const roleClass =
normalizedRole === "user"
? "user"
: normalizedRole === "assistant"
? "assistant"
: "other";
normalizedRole === "user" ? "user" : normalizedRole === "assistant" ? "assistant" : "other";
const timestamp = new Date(group.timestamp).toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
@@ -143,8 +137,7 @@ export function renderMessageGroup(
renderGroupedMessage(
item.message,
{
isStreaming:
group.isStreaming && index === group.messages.length - 1,
isStreaming: group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
},
opts.onOpenSidebar,
@@ -159,10 +152,7 @@ export function renderMessageGroup(
`;
}
function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
) {
function renderAvatar(role: string, assistant?: Pick<AssistantIdentity, "name" | "avatar">) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
const assistantAvatar = assistant?.avatar?.trim() || "";
@@ -179,7 +169,7 @@ function renderAvatar(
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
: normalized === "tool"
? "tool"
: "other";
@@ -199,9 +189,7 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) ||
/^\//.test(value) // Relative paths from avatar endpoint
/^https?:\/\//i.test(value) || /^data:image\//i.test(value) || /^\//.test(value) // Relative paths from avatar endpoint
);
}
@@ -245,13 +233,9 @@ function renderGroupedMessage(
const extractedText = extractTextCached(message);
const extractedThinking =
opts.showReasoning && role === "assistant"
? extractThinkingCached(message)
: null;
opts.showReasoning && role === "assistant" ? extractThinkingCached(message) : null;
const markdownBase = extractedText?.trim() ? extractedText : null;
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const reasoningMarkdown = extractedThinking ? formatReasoningMarkdown(extractedThinking) : null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
@@ -265,9 +249,7 @@ function renderGroupedMessage(
.join(" ");
if (!markdown && hasToolCards && isToolResult) {
return html`${toolCards.map((card) =>
renderToolCardSidebar(card, onOpenSidebar),
)}`;
return html`${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}`;
}
if (!markdown && !hasToolCards && !hasImages) return nothing;
@@ -276,14 +258,18 @@ function renderGroupedMessage(
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${renderMessageImages(images)}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${
reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing
}
${
markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
</div>
`;
+2 -6
View File
@@ -90,13 +90,9 @@ export function extractThinking(message: unknown): string | null {
const rawText = extractRawText(message);
if (!rawText) return null;
const matches = [
...rawText.matchAll(
/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi,
),
...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi),
];
const extracted = matches
.map((m) => (m[1] ?? "").trim())
.filter(Boolean);
const extracted = matches.map((m) => (m[1] ?? "").trim()).filter(Boolean);
return extracted.length > 0 ? extracted.join("\n") : null;
}
+12 -2
View File
@@ -44,8 +44,18 @@ describe("message-normalizer", () => {
expect(result.role).toBe("assistant");
expect(result.content).toHaveLength(2);
expect(result.content[0]).toEqual({ type: "text", text: "Here is the result", name: undefined, args: undefined });
expect(result.content[1]).toEqual({ type: "tool_use", text: undefined, name: "bash", args: { command: "ls" } });
expect(result.content[0]).toEqual({
type: "text",
text: "Here is the result",
name: undefined,
args: undefined,
});
expect(result.content[1]).toEqual({
type: "tool_use",
text: undefined,
name: "bash",
args: { command: "ls" },
});
});
it("normalizes message with text field (alternative format)", () => {
+2 -6
View File
@@ -2,10 +2,7 @@
* Message normalization utilities for chat rendering.
*/
import type {
NormalizedMessage,
MessageContentItem,
} from "../types/chat-types";
import type { NormalizedMessage, MessageContentItem } from "../types/chat-types";
/**
* Normalize a raw message object into a consistent structure.
@@ -16,8 +13,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
// Detect tool messages by common gateway shapes.
// Some tool events come through as assistant role with tool_* items in the content array.
const hasToolId =
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const contentRaw = m.content;
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
+31 -34
View File
@@ -4,10 +4,7 @@ import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import { icons } from "../icons";
import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants";
import {
formatToolOutputForSidebar,
getTruncatedPreview,
} from "./tool-helpers";
import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers";
import { isToolResultMessage } from "./message-normalizer";
import { extractTextCached } from "./message-extract";
@@ -38,10 +35,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
cards.push({ kind: "result", name, text });
}
if (
isToolResultMessage(message) &&
!cards.some((card) => card.kind === "result")
) {
if (isToolResultMessage(message) && !cards.some((card) => card.kind === "result")) {
const name =
(typeof m.toolName === "string" && m.toolName) ||
(typeof m.tool_name === "string" && m.tool_name) ||
@@ -53,10 +47,7 @@ export function extractToolCards(message: unknown): ToolCard[] {
return cards;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: string) => void,
) {
export function renderToolCardSidebar(card: ToolCard, onOpenSidebar?: (content: string) => void) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasText = Boolean(card.text?.trim());
@@ -86,36 +77,42 @@ export function renderToolCardSidebar(
@click=${handleClick}
role=${canClick ? "button" : nothing}
tabindex=${canClick ? "0" : nothing}
@keydown=${canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick?.();
}
: nothing}
@keydown=${
canClick
? (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleClick?.();
}
: nothing
}
>
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${icons[display.icon]}</span>
<span>${display.label}</span>
</div>
${canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
: nothing}
${
canClick
? html`<span class="chat-tool-card__action">${hasText ? "View" : ""} ${icons.check}</span>`
: nothing
}
${isEmpty && !canClick ? html`<span class="chat-tool-card__status">${icons.check}</span>` : nothing}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${isEmpty
? html`<div class="chat-tool-card__status-text muted">Completed</div>`
: nothing}
${showCollapsed
? html`<div class="chat-tool-card__preview mono">${getTruncatedPreview(card.text!)}</div>`
: nothing}
${showInline
? html`<div class="chat-tool-card__inline mono">${card.text}</div>`
: nothing}
${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
}
${showInline ? html`<div class="chat-tool-card__inline mono">${card.text}</div>` : nothing}
</div>
`;
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe("tool-helpers", () => {
});
it("formats valid JSON array as code block", () => {
const input = '[1, 2, 3]';
const input = "[1, 2, 3]";
const result = formatToolOutputForSidebar(input);
expect(result).toBe(`\`\`\`json
+7 -5
View File
@@ -24,7 +24,7 @@ export class ResizableDivider extends LitElement {
flex-shrink: 0;
position: relative;
}
:host::before {
content: "";
position: absolute;
@@ -33,18 +33,20 @@ export class ResizableDivider extends LitElement {
right: -4px;
bottom: 0;
}
:host(:hover) {
background: var(--accent, #007bff);
}
:host(.dragging) {
background: var(--accent, #007bff);
}
`;
render() {
return html``;
return html`
`;
}
connectedCallback() {
@@ -89,7 +91,7 @@ export class ResizableDivider extends LitElement {
detail: { splitRatio: newRatio },
bubbles: true,
composed: true,
})
}),
);
};
+5 -19
View File
@@ -29,12 +29,7 @@ const rootSchema = {
type: "boolean",
},
bind: {
anyOf: [
{ const: "auto" },
{ const: "lan" },
{ const: "tailnet" },
{ const: "loopback" },
],
anyOf: [{ const: "auto" }, { const: "lan" }, { const: "tailnet" }, { const: "loopback" }],
},
},
};
@@ -57,17 +52,12 @@ describe("config form renderer", () => {
container,
);
const tokenInput = container.querySelector(
"input[type='password']",
) as HTMLInputElement | null;
const tokenInput = container.querySelector("input[type='password']") as HTMLInputElement | null;
expect(tokenInput).not.toBeNull();
if (!tokenInput) return;
tokenInput.value = "abc123";
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(
["gateway", "auth", "token"],
"abc123",
);
expect(onPatch).toHaveBeenCalledWith(["gateway", "auth", "token"], "abc123");
const tokenButton = Array.from(
container.querySelectorAll<HTMLButtonElement>(".cfg-segmented__btn"),
@@ -76,9 +66,7 @@ describe("config form renderer", () => {
tokenButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector(
"input[type='checkbox']",
) as HTMLInputElement | null;
const checkbox = container.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(checkbox).not.toBeNull();
if (!checkbox) return;
checkbox.checked = true;
@@ -101,9 +89,7 @@ describe("config form renderer", () => {
container,
);
const addButton = container.querySelector(
".cfg-array__add",
) as HTMLButtonElement | null;
const addButton = container.querySelector(".cfg-array__add") as HTMLButtonElement | null;
expect(addButton).not.toBeUndefined();
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
+1 -4
View File
@@ -1,8 +1,5 @@
import type { GatewayBrowserClient } from "../gateway";
import {
normalizeAssistantIdentity,
type AssistantIdentity,
} from "../assistant-identity";
import { normalizeAssistantIdentity, type AssistantIdentity } from "../assistant-identity";
export type AssistantIdentityState = {
client: GatewayBrowserClient | null;
+1 -5
View File
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import {
handleChatEvent,
type ChatEventPayload,
type ChatState,
} from "./chat";
import { handleChatEvent, type ChatEventPayload, type ChatState } from "./chat";
function createState(overrides: Partial<ChatState> = {}): ChatState {
return {
+3 -12
View File
@@ -144,9 +144,7 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
try {
await state.client.request(
"chat.abort",
runId
? { sessionKey: state.sessionKey, runId }
: { sessionKey: state.sessionKey },
runId ? { sessionKey: state.sessionKey, runId } : { sessionKey: state.sessionKey },
);
return true;
} catch (err) {
@@ -155,20 +153,13 @@ export async function abortChatRun(state: ChatState): Promise<boolean> {
}
}
export function handleChatEvent(
state: ChatState,
payload?: ChatEventPayload,
) {
export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null;
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
// See https://github.com/openclaw/openclaw/issues/1909
if (
payload.runId &&
state.chatRunId &&
payload.runId !== state.chatRunId
) {
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
if (payload.state === "final") return "final";
return null;
}
+5 -5
View File
@@ -46,11 +46,11 @@ describe("applyConfigSnapshot", () => {
config: { gateway: { mode: "remote", port: 9999 } },
valid: true,
issues: [],
raw: "{\n \"gateway\": { \"mode\": \"remote\", \"port\": 9999 }\n}\n",
raw: '{\n "gateway": { "mode": "remote", "port": 9999 }\n}\n',
});
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
);
});
@@ -129,7 +129,7 @@ describe("updateConfigFormValue", () => {
updateConfigFormValue(state, ["gateway", "port"], 18789);
expect(state.configRaw).toBe(
"{\n \"gateway\": {\n \"mode\": \"local\",\n \"port\": 18789\n }\n}\n",
'{\n "gateway": {\n "mode": "local",\n "port": 18789\n }\n}\n',
);
});
});
@@ -142,7 +142,7 @@ describe("applyConfig", () => {
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
state.configFormMode = "raw";
state.configRaw = "{\n agent: { workspace: \"~/openclaw\" }\n}\n";
state.configRaw = '{\n agent: { workspace: "~/openclaw" }\n}\n';
state.configSnapshot = {
hash: "hash-123",
};
@@ -150,7 +150,7 @@ describe("applyConfig", () => {
await applyConfig(state);
expect(request).toHaveBeenCalledWith("config.apply", {
raw: "{\n agent: { workspace: \"~/openclaw\" }\n}\n",
raw: '{\n agent: { workspace: "~/openclaw" }\n}\n',
baseHash: "hash-123",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
});
+6 -23
View File
@@ -1,9 +1,5 @@
import type { GatewayBrowserClient } from "../gateway";
import type {
ConfigSchemaResponse,
ConfigSnapshot,
ConfigUiHints,
} from "../types";
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types";
import {
cloneConfigObject,
removePathValue,
@@ -57,10 +53,7 @@ export async function loadConfigSchema(state: ConfigState) {
if (state.configSchemaLoading) return;
state.configSchemaLoading = true;
try {
const res = (await state.client.request(
"config.schema",
{},
)) as ConfigSchemaResponse;
const res = (await state.client.request("config.schema", {})) as ConfigSchemaResponse;
applyConfigSchema(state, res);
} catch (err) {
state.lastError = String(err);
@@ -69,10 +62,7 @@ export async function loadConfigSchema(state: ConfigState) {
}
}
export function applyConfigSchema(
state: ConfigState,
res: ConfigSchemaResponse,
) {
export function applyConfigSchema(state: ConfigState, res: ConfigSchemaResponse) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
@@ -175,9 +165,7 @@ export function updateConfigFormValue(
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
@@ -186,13 +174,8 @@ export function updateConfigFormValue(
}
}
export function removeConfigFormValue(
state: ConfigState,
path: Array<string | number>,
) {
const base = cloneConfigObject(
state.configForm ?? state.configSnapshot?.config ?? {},
);
export function removeConfigFormValue(state: ConfigState, path: Array<string | number>) {
const base = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;
+3 -7
View File
@@ -22,16 +22,14 @@ export function setPathValue(
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
current[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
record[key] = typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
@@ -59,9 +57,7 @@ export function removePathValue(
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
current = (current as Record<string, unknown>)[key] as
| Record<string, unknown>
| unknown[];
current = (current as Record<string, unknown>)[key] as Record<string, unknown> | unknown[];
}
if (current == null) return;
}
+2 -7
View File
@@ -103,8 +103,7 @@ export async function addCronJob(state: CronState) {
wakeMode: state.cronForm.wakeMode,
payload,
isolation:
state.cronForm.postToMainPrefix.trim() &&
state.cronForm.sessionTarget === "isolated"
state.cronForm.postToMainPrefix.trim() && state.cronForm.sessionTarget === "isolated"
? { postToMainPrefix: state.cronForm.postToMainPrefix.trim() }
: undefined,
};
@@ -125,11 +124,7 @@ export async function addCronJob(state: CronState) {
}
}
export async function toggleCronJob(
state: CronState,
job: CronJob,
enabled: boolean,
) {
export async function toggleCronJob(state: CronState, job: CronJob, enabled: boolean) {
if (!state.client || !state.connected || state.cronBusy) return;
state.cronBusy = true;
state.cronError = null;
+1 -3
View File
@@ -29,9 +29,7 @@ export async function loadDebug(state: DebugState) {
state.debugStatus = status as StatusSummary;
state.debugHealth = health as HealthSnapshot;
const modelPayload = models as { models?: unknown[] } | undefined;
state.debugModels = Array.isArray(modelPayload?.models)
? modelPayload?.models
: [];
state.debugModels = Array.isArray(modelPayload?.models) ? modelPayload?.models : [];
state.debugHeartbeat = heartbeat as unknown;
} catch (err) {
state.debugCallError = String(err);
+1 -3
View File
@@ -118,9 +118,7 @@ export async function revokeDeviceToken(
params: { deviceId: string; role: string },
) {
if (!state.client || !state.connected) return;
const confirmed = window.confirm(
`Revoke token for ${params.deviceId} (${params.role})?`,
);
const confirmed = window.confirm(`Revoke token for ${params.deviceId} (${params.role})?`);
if (!confirmed) return;
try {
await state.client.request("device.token.revoke", params);
+4 -1
View File
@@ -80,6 +80,9 @@ export function addExecApproval(
return next;
}
export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] {
export function removeExecApproval(
queue: ExecApprovalRequest[],
id: string,
): ExecApprovalRequest[] {
return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id);
}
+2 -7
View File
@@ -34,9 +34,7 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile;
};
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsTarget = { kind: "gateway" } | { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
@@ -120,10 +118,7 @@ export async function saveExecApprovals(
state.lastError = "Exec approvals hash missing; reload and retry.";
return;
}
const file =
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
const file = state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {};
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash });
if (!rpc) {
state.lastError = "Select a node before saving exec approvals.";
+4 -18
View File
@@ -16,14 +16,7 @@ export type LogsState = {
};
const LOG_BUFFER_LIMIT = 2000;
const LEVELS = new Set<LogLevel>([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
]);
const LEVELS = new Set<LogLevel>(["trace", "debug", "info", "warn", "error", "fatal"]);
function parseMaybeJsonString(value: unknown) {
if (typeof value !== "string") return null;
@@ -53,11 +46,7 @@ export function parseLogLine(line: string): LogEntry {
? (obj._meta as Record<string, unknown>)
: null;
const time =
typeof obj.time === "string"
? obj.time
: typeof meta?.date === "string"
? meta?.date
: null;
typeof obj.time === "string" ? obj.time : typeof meta?.date === "string" ? meta?.date : null;
const level = normalizeLevel(meta?.logLevelName ?? meta?.level);
const contextCandidate =
@@ -94,17 +83,14 @@ export function parseLogLine(line: string): LogEntry {
}
}
export async function loadLogs(
state: LogsState,
opts?: { reset?: boolean; quiet?: boolean },
) {
export async function loadLogs(state: LogsState, opts?: { reset?: boolean; quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.logsLoading && !opts?.quiet) return;
if (!opts?.quiet) state.logsLoading = true;
state.logsError = null;
try {
const res = await state.client.request("logs.tail", {
cursor: opts?.reset ? undefined : state.logsCursor ?? undefined,
cursor: opts?.reset ? undefined : (state.logsCursor ?? undefined),
limit: state.logsLimit,
maxBytes: state.logsMaxBytes,
});
+1 -4
View File
@@ -8,10 +8,7 @@ export type NodesState = {
lastError: string | null;
};
export async function loadNodes(
state: NodesState,
opts?: { quiet?: boolean },
) {
export async function loadNodes(state: NodesState, opts?: { quiet?: boolean }) {
if (!state.client || !state.connected) return;
if (state.nodesLoading) return;
state.nodesLoading = true;
+1 -3
View File
@@ -17,9 +17,7 @@ export async function loadPresence(state: PresenceState) {
state.presenceError = null;
state.presenceStatus = null;
try {
const res = (await state.client.request("system-presence", {})) as
| PresenceEntry[]
| undefined;
const res = (await state.client.request("system-presence", {})) as PresenceEntry[] | undefined;
if (Array.isArray(res)) {
state.presenceEntries = res;
state.presenceStatus = res.length === 0 ? "No instances yet." : null;
+1 -2
View File
@@ -30,8 +30,7 @@ export async function loadSessions(
try {
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
const activeMinutes =
overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
const params: Record<string, unknown> = {
includeGlobal,
+3 -13
View File
@@ -45,9 +45,7 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
state.skillsLoading = true;
state.skillsError = null;
try {
const res = (await state.client.request("skills.status", {})) as
| SkillStatusReport
| undefined;
const res = (await state.client.request("skills.status", {})) as SkillStatusReport | undefined;
if (res) state.skillsReport = res;
} catch (err) {
state.skillsError = getErrorMessage(err);
@@ -56,19 +54,11 @@ export async function loadSkills(state: SkillsState, options?: LoadSkillsOptions
}
}
export function updateSkillEdit(
state: SkillsState,
skillKey: string,
value: string,
) {
export function updateSkillEdit(state: SkillsState, skillKey: string, value: string) {
state.skillEdits = { ...state.skillEdits, [skillKey]: value };
}
export async function updateSkillEnabled(
state: SkillsState,
skillKey: string,
enabled: boolean,
) {
export async function updateSkillEnabled(state: SkillsState, skillKey: string, enabled: boolean) {
if (!state.client || !state.connected) return;
state.skillsBusyKey = skillKey;
state.skillsError = null;
+2 -6
View File
@@ -36,9 +36,7 @@ describe("chat focus mode", () => {
expect(shell).not.toBeNull();
expect(shell?.classList.contains("shell--chat-focus")).toBe(false);
const toggle = app.querySelector<HTMLButtonElement>(
'button[title^="Toggle focus mode"]',
);
const toggle = app.querySelector<HTMLButtonElement>('button[title^="Toggle focus mode"]');
expect(toggle).not.toBeNull();
toggle?.click();
@@ -47,9 +45,7 @@ describe("chat focus mode", () => {
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await app.updateComplete;
expect(app.tab).toBe("channels");
+4 -1
View File
@@ -42,7 +42,10 @@ export function clampText(value: string, max = 120): string {
return `${value.slice(0, Math.max(0, max - 1))}`;
}
export function truncateText(value: string, max: number): {
export function truncateText(
value: string,
max: number,
): {
text: string;
truncated: boolean;
total: number;
+217 -31
View File
@@ -5,40 +5,223 @@ import { html, type TemplateResult } from "lit";
export const icons = {
// Navigation icons
messageSquare: html`<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
barChart: html`<svg viewBox="0 0 24 24"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>`,
link: html`<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
radio: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"/></svg>`,
fileText: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/></svg>`,
zap: html`<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
monitor: html`<svg viewBox="0 0 24 24"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`,
settings: html`<svg viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
bug: html`<svg viewBox="0 0 24 24"><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>`,
scrollText: html`<svg viewBox="0 0 24 24"><path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M15 8h-5"/><path d="M15 12h-5"/></svg>`,
folder: html`<svg viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>`,
messageSquare: html`
<svg viewBox="0 0 24 24">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
`,
barChart: html`
<svg viewBox="0 0 24 24">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="16" />
</svg>
`,
link: html`
<svg viewBox="0 0 24 24">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
`,
radio: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="2" />
<path
d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"
/>
</svg>
`,
fileText: html`
<svg viewBox="0 0 24 24">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" x2="8" y1="13" y2="13" />
<line x1="16" x2="8" y1="17" y2="17" />
<line x1="10" x2="8" y1="9" y2="9" />
</svg>
`,
zap: html`
<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
`,
monitor: html`
<svg viewBox="0 0 24 24">
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
`,
settings: html`
<svg viewBox="0 0 24 24">
<path
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
`,
bug: html`
<svg viewBox="0 0 24 24">
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
`,
scrollText: html`
<svg viewBox="0 0 24 24">
<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M15 8h-5" />
<path d="M15 12h-5" />
</svg>
`,
folder: html`
<svg viewBox="0 0 24 24">
<path
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
/>
</svg>
`,
// UI icons
menu: html`<svg viewBox="0 0 24 24"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`,
x: html`<svg viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,
check: html`<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>`,
copy: html`<svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`,
search: html`<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
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="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"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/></svg>`,
book: html`<svg viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>`,
loader: html`<svg viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>`,
menu: html`
<svg viewBox="0 0 24 24">
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
`,
x: html`
<svg viewBox="0 0 24 24">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
`,
check: html`
<svg viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5" /></svg>
`,
copy: html`
<svg viewBox="0 0 24 24">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
`,
search: html`
<svg viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
`,
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="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" />
<path d="M3.477 10.896a4 4 0 0 1 .585-.396" />
<path d="M19.938 10.5a4 4 0 0 1 .585.396" />
<path d="M6 18a4 4 0 0 1-1.967-.516" />
<path d="M19.967 17.484A4 4 0 0 1 18 18" />
</svg>
`,
book: html`
<svg viewBox="0 0 24 24">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
</svg>
`,
loader: html`
<svg viewBox="0 0 24 24">
<path d="M12 2v4" />
<path d="m16.2 7.8 2.9-2.9" />
<path d="M18 12h4" />
<path d="m16.2 16.2 2.9 2.9" />
<path d="M12 18v4" />
<path d="m4.9 19.1 2.9-2.9" />
<path d="M2 12h4" />
<path d="m4.9 4.9 2.9 2.9" />
</svg>
`,
// Tool icons
wrench: html`<svg viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
fileCode: html`<svg viewBox="0 0 24 24"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/></svg>`,
edit: html`<svg viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
penLine: html`<svg viewBox="0 0 24 24"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
paperclip: html`<svg viewBox="0 0 24 24"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>`,
globe: html`<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`,
image: html`<svg viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>`,
smartphone: html`<svg viewBox="0 0 24 24"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>`,
plug: html`<svg viewBox="0 0 24 24"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><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>`,
puzzle: html`<svg viewBox="0 0 24 24"><path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"/></svg>`,
wrench: html`
<svg viewBox="0 0 24 24">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
`,
fileCode: html`
<svg viewBox="0 0 24 24">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<path d="m10 13-2 2 2 2" />
<path d="m14 17 2-2-2-2" />
</svg>
`,
edit: html`
<svg viewBox="0 0 24 24">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
`,
penLine: html`
<svg viewBox="0 0 24 24">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
`,
paperclip: html`
<svg viewBox="0 0 24 24">
<path
d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"
/>
</svg>
`,
globe: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
`,
image: html`
<svg viewBox="0 0 24 24">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
`,
smartphone: html`
<svg viewBox="0 0 24 24">
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
<path d="M12 18h.01" />
</svg>
`,
plug: html`
<svg viewBox="0 0 24 24">
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<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>
`,
puzzle: html`
<svg viewBox="0 0 24 24">
<path
d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.076.874.54 1.02 1.02a2.5 2.5 0 1 0 3.237-3.237c-.48-.146-.944-.505-1.02-1.02a.98.98 0 0 1 .303-.917l1.526-1.526A2.402 2.402 0 0 1 11.998 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.236 3.236c-.464.18-.894.527-.967 1.02Z"
/>
</svg>
`,
} as const;
export type IconName = keyof typeof icons;
@@ -52,7 +235,10 @@ export function renderIcon(name: IconName, className = "nav-item__icon"): Templa
}
// Legacy function for compatibility
export function renderEmojiIcon(iconContent: string | TemplateResult, className: string): TemplateResult {
export function renderEmojiIcon(
iconContent: string | TemplateResult,
className: string,
): TemplateResult {
return html`<span class=${className} aria-hidden="true">${iconContent}</span>`;
}
+2 -6
View File
@@ -75,13 +75,9 @@ describe("control UI routing", () => {
const app = mountApp("/chat");
await app.updateComplete;
const link = app.querySelector<HTMLAnchorElement>(
'a.nav-item[href="/channels"]',
);
const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/channels"]');
expect(link).not.toBeNull();
link?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
);
link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }));
await app.updateComplete;
expect(app.tab).toBe("channels");
+1 -3
View File
@@ -37,9 +37,7 @@ const TAB_PATHS: Record<Tab, string> = {
logs: "/logs",
};
const PATH_TO_TAB = new Map(
Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]),
);
const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab]));
export function normalizeBasePath(basePath: string): string {
if (!basePath) return "";
+6 -15
View File
@@ -49,22 +49,16 @@ export function loadSettings(): UiSettings {
? parsed.sessionKey.trim()
: defaults.sessionKey,
lastActiveSessionKey:
typeof parsed.lastActiveSessionKey === "string" &&
parsed.lastActiveSessionKey.trim()
typeof parsed.lastActiveSessionKey === "string" && parsed.lastActiveSessionKey.trim()
? parsed.lastActiveSessionKey.trim()
: (typeof parsed.sessionKey === "string" &&
parsed.sessionKey.trim()) ||
: (typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()) ||
defaults.lastActiveSessionKey,
theme:
parsed.theme === "light" ||
parsed.theme === "dark" ||
parsed.theme === "system"
parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system"
? parsed.theme
: defaults.theme,
chatFocusMode:
typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode
: defaults.chatFocusMode,
typeof parsed.chatFocusMode === "boolean" ? parsed.chatFocusMode : defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
@@ -76,12 +70,9 @@ export function loadSettings(): UiSettings {
? parsed.splitRatio
: defaults.splitRatio,
navCollapsed:
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed
: defaults.navCollapsed,
typeof parsed.navCollapsed === "boolean" ? parsed.navCollapsed : defaults.navCollapsed,
navGroupsCollapsed:
typeof parsed.navGroupsCollapsed === "object" &&
parsed.navGroupsCollapsed !== null
typeof parsed.navGroupsCollapsed === "object" && parsed.navGroupsCollapsed !== null
? parsed.navGroupsCollapsed
: defaults.navGroupsCollapsed,
};
+2 -7
View File
@@ -55,8 +55,7 @@ export const startThemeTransition = ({
const document_ = documentReference as DocumentWithViewTransition;
const prefersReducedMotion = hasReducedMotionPreference();
const canUseViewTransition =
Boolean(document_.startViewTransition) && !prefersReducedMotion;
const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion;
if (canUseViewTransition) {
let xPercent = 0.5;
@@ -71,11 +70,7 @@ export const startThemeTransition = ({
yPercent = clamp01(context.pointerClientY / window.innerHeight);
} else if (context?.element) {
const rect = context.element.getBoundingClientRect();
if (
rect.width > 0 &&
rect.height > 0 &&
typeof window !== "undefined"
) {
if (rect.width > 0 && rect.height > 0 && typeof window !== "undefined") {
xPercent = clamp01((rect.left + rect.width / 2) / window.innerWidth);
yPercent = clamp01((rect.top + rect.height / 2) / window.innerHeight);
}
+1 -3
View File
@@ -5,9 +5,7 @@ export function getSystemTheme(): ResolvedTheme {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
+15 -3
View File
@@ -90,7 +90,13 @@
},
"act": {
"label": "act",
"detailKeys": ["request.kind", "request.ref", "request.selector", "request.text", "request.value"]
"detailKeys": [
"request.kind",
"request.ref",
"request.selector",
"request.text",
"request.value"
]
}
}
},
@@ -117,9 +123,15 @@
"approve": { "label": "approve", "detailKeys": ["requestId"] },
"reject": { "label": "reject", "detailKeys": ["requestId"] },
"notify": { "label": "notify", "detailKeys": ["node", "nodeId", "title", "body"] },
"camera_snap": { "label": "camera snap", "detailKeys": ["node", "nodeId", "facing", "deviceId"] },
"camera_snap": {
"label": "camera snap",
"detailKeys": ["node", "nodeId", "facing", "deviceId"]
},
"camera_list": { "label": "camera list", "detailKeys": ["node", "nodeId"] },
"camera_clip": { "label": "camera clip", "detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"] },
"camera_clip": {
"label": "camera clip",
"detailKeys": ["node", "nodeId", "facing", "duration", "durationMs"]
},
"screen_record": {
"label": "screen record",
"detailKeys": ["node", "nodeId", "duration", "durationMs", "fps", "screenIndex"]
+2 -5
View File
@@ -153,8 +153,7 @@ export function resolveToolDisplay(params: {
detail = resolveWriteDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
const detailKeys = actionSpec?.detailKeys ?? spec?.detailKeys ?? FALLBACK.detailKeys ?? [];
if (!detail && detailKeys.length > 0) {
detail = resolveDetailFromKeys(params.args, detailKeys);
}
@@ -192,7 +191,5 @@ export function formatToolSummary(display: ToolDisplay): string {
function shortenHomeInString(input: string): string {
if (!input) return input;
return input
.replace(/\/Users\/[^/]+/g, "~")
.replace(/\/home\/[^/]+/g, "~");
return input.replace(/\/Users\/[^/]+/g, "~").replace(/\/home\/[^/]+/g, "~");
}
+1 -7
View File
@@ -514,13 +514,7 @@ export type StatusSummary = Record<string, unknown>;
export type HealthSnapshot = Record<string, unknown>;
export type LogLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogEntry = {
raw: string;
+23 -25
View File
@@ -2,12 +2,7 @@ import { html } from "lit";
import type { ConfigUiHints } from "../types";
import type { ChannelsProps } from "./channels.types";
import {
analyzeConfigSchema,
renderNode,
schemaType,
type JsonSchema,
} from "./config-form";
import { analyzeConfigSchema, renderNode, schemaType, type JsonSchema } from "./config-form";
type ChannelConfigFormProps = {
channelId: string;
@@ -61,9 +56,7 @@ function resolveChannelValue(
(fromChannels && typeof fromChannels === "object"
? (fromChannels as Record<string, unknown>)
: null) ??
(fallback && typeof fallback === "object"
? (fallback as Record<string, unknown>)
: null);
(fallback && typeof fallback === "object" ? (fallback as Record<string, unknown>) : null);
return resolved ?? {};
}
@@ -71,11 +64,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);
@@ -95,24 +92,25 @@ export function renderChannelConfigForm(props: ChannelConfigFormProps) {
`;
}
export function renderChannelConfigSection(params: {
channelId: string;
props: ChannelsProps;
}) {
export function renderChannelConfigSection(params: { channelId: string; props: ChannelsProps }) {
const { channelId, props } = params;
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"
+10 -6
View File
@@ -37,18 +37,22 @@ export function renderDiscordCard(params: {
</div>
</div>
${discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
discord?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${discord.lastError}
</div>`
: nothing}
: nothing
}
${discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
discord?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${discord.probe.ok ? "ok" : "failed"} ·
${discord.probe.status ?? ""} ${discord.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "discord", props })}
+15 -9
View File
@@ -34,9 +34,11 @@ export function renderGoogleChatCard(params: {
<div>
<span class="label">Audience</span>
<span>
${googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"}
${
googleChat?.audienceType
? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}`
: "n/a"
}
</span>
</div>
<div>
@@ -49,18 +51,22 @@ export function renderGoogleChatCard(params: {
</div>
</div>
${googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
googleChat?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${googleChat.lastError}
</div>`
: nothing}
: nothing
}
${googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
googleChat?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${googleChat.probe.ok ? "ok" : "failed"} ·
${googleChat.probe.status ?? ""} ${googleChat.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "googlechat", props })}
+10 -6
View File
@@ -37,18 +37,22 @@ export function renderIMessageCard(params: {
</div>
</div>
${imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
imessage?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${imessage.lastError}
</div>`
: nothing}
: nothing
}
${imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
imessage?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${imessage.probe.ok ? "ok" : "failed"} ·
${imessage.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "imessage", props })}
+27 -19
View File
@@ -82,7 +82,7 @@ export function renderNostrProfileForm(params: {
placeholder?: string;
maxLength?: number;
help?: string;
} = {}
} = {},
) => {
const { type = "text", placeholder, maxLength, help } = opts;
const value = state.values[field] ?? "";
@@ -169,13 +169,17 @@ 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.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.success
? html`<div class="callout success" style="margin-bottom: 12px;">${state.success}</div>`
: nothing
}
${renderPicturePreview()}
@@ -204,8 +208,9 @@ export function renderNostrProfileForm(params: {
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;">
<div style="font-weight: 500; margin-bottom: 12px; color: var(--text-muted);">Advanced</div>
@@ -232,7 +237,8 @@ export function renderNostrProfileForm(params: {
})}
</div>
`
: nothing}
: nothing
}
<div style="display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap;">
<button
@@ -267,11 +273,15 @@ export function renderNostrProfileForm(params: {
</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>
`;
}
@@ -284,7 +294,7 @@ export function renderNostrProfileForm(params: {
* Create initial form state from existing profile
*/
export function createNostrProfileFormState(
profile: NostrProfileType | undefined
profile: NostrProfileType | undefined,
): NostrProfileFormState {
const values: NostrProfileType = {
name: profile?.name ?? "",
@@ -305,8 +315,6 @@ export function createNostrProfileFormState(
error: null,
success: null,
fieldErrors: {},
showAdvanced: Boolean(
profile?.banner || profile?.website || profile?.nip05 || profile?.lud16
),
showAdvanced: Boolean(profile?.banner || profile?.website || profile?.nip05 || profile?.lud16),
};
}
+59 -42
View File
@@ -44,8 +44,7 @@ export function renderNostrCard(params: {
const summaryConfigured = nostr?.configured ?? primaryAccount?.configured ?? false;
const summaryRunning = nostr?.running ?? primaryAccount?.running ?? false;
const summaryPublicKey =
nostr?.publicKey ??
(primaryAccount as { publicKey?: string } | undefined)?.publicKey;
nostr?.publicKey ?? (primaryAccount as { publicKey?: string } | undefined)?.publicKey;
const summaryLastStartAt = nostr?.lastStartAt ?? primaryAccount?.lastStartAt ?? null;
const summaryLastError = nostr?.lastError ?? primaryAccount?.lastError ?? null;
const hasMultipleAccounts = nostrAccounts.length > 1;
@@ -79,11 +78,13 @@ export function renderNostrCard(params: {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">${account.lastError}</div>
`
: nothing}
: nothing
}
</div>
</div>
`;
@@ -100,17 +101,19 @@ export function renderNostrCard(params: {
}
const profile =
(primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined)?.profile ?? nostr?.profile;
(
primaryAccount as
| {
profile?: {
name?: string;
displayName?: string;
about?: string;
picture?: string;
nip05?: string;
};
}
| undefined
)?.profile ?? nostr?.profile;
const { name, displayName, about, picture, nip05 } = profile ?? {};
const hasAnyProfileData = name || displayName || about || picture || nip05;
@@ -118,8 +121,9 @@ export function renderNostrCard(params: {
<div style="margin-top: 16px; padding: 12px; background: var(--bg-secondary); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div style="font-weight: 500;">Profile</div>
${summaryConfigured
? html`
${
summaryConfigured
? html`
<button
class="btn btn-sm"
@click=${onEditProfile}
@@ -128,13 +132,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}
@@ -146,22 +153,28 @@ export function renderNostrCard(params: {
/>
</div>
`
: nothing}
: nothing
}
${name ? html`<div><span class="label">Name</span><span>${name}</span></div>` : nothing}
${displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing}
${about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing}
${
displayName
? html`<div><span class="label">Display Name</span><span>${displayName}</span></div>`
: nothing
}
${
about
? html`<div><span class="label">About</span><span style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;">${about}</span></div>`
: nothing
}
${nip05 ? html`<div><span class="label">NIP-05</span><span>${nip05}</span></div>` : nothing}
</div>
`
: html`
<div style="color: var(--text-muted); font-size: 13px;">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`}
: html`
<div style="color: var(--text-muted); font-size: 13px">
No profile set. Click "Edit Profile" to add your name, bio, and avatar.
</div>
`
}
</div>
`;
};
@@ -172,13 +185,14 @@ export function renderNostrCard(params: {
<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>
@@ -199,11 +213,14 @@ export function renderNostrCard(params: {
<span>${summaryLastStartAt ? formatAgo(summaryLastStartAt) : "n/a"}</span>
</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()}
+10 -6
View File
@@ -41,18 +41,22 @@ export function renderSignalCard(params: {
</div>
</div>
${signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
signal?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${signal.lastError}
</div>`
: nothing}
: nothing
}
${signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
signal?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${signal.probe.ok ? "ok" : "failed"} ·
${signal.probe.status ?? ""} ${signal.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "signal", props })}
+10 -6
View File
@@ -37,18 +37,22 @@ export function renderSlackCard(params: {
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
: nothing
}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""} ${slack.probe.error ?? ""}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: "slack", props })}
+21 -13
View File
@@ -39,13 +39,15 @@ export function renderTelegramCard(params: {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
: nothing
}
</div>
</div>
`;
@@ -57,13 +59,14 @@ export function renderTelegramCard(params: {
<div class="card-sub">Bot status and channel configuration.</div>
${accountCountLabel}
${hasMultipleAccounts
? html`
${
hasMultipleAccounts
? html`
<div class="account-card-list">
${telegramAccounts.map((account) => renderAccountCard(account))}
</div>
`
: html`
: html`
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
@@ -86,20 +89,25 @@ export function renderTelegramCard(params: {
<span>${telegram?.lastProbeAt ? formatAgo(telegram.lastProbeAt) : "n/a"}</span>
</div>
</div>
`}
`
}
${telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
telegram?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${telegram.lastError}
</div>`
: nothing}
: nothing
}
${telegram?.probe
? html`<div class="callout" style="margin-top: 12px;">
${
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 })}
+28 -47
View File
@@ -15,11 +15,7 @@ import type {
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type {
ChannelKey,
ChannelsChannelData,
ChannelsProps,
} from "./channels.types";
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./channels.types";
import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
@@ -33,12 +29,8 @@ import { renderWhatsAppCard } from "./channels.whatsapp";
export function renderChannels(props: ChannelsProps) {
const channels = props.snapshot?.channels as Record<string, unknown> | null;
const whatsapp = (channels?.whatsapp ?? undefined) as
| WhatsAppStatus
| undefined;
const telegram = (channels?.telegram ?? undefined) as
| TelegramStatus
| undefined;
const whatsapp = (channels?.whatsapp ?? undefined) as WhatsAppStatus | undefined;
const telegram = (channels?.telegram ?? undefined) as TelegramStatus | undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const googlechat = (channels?.googlechat ?? null) as GoogleChatStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
@@ -82,11 +74,13 @@ export function renderChannels(props: ChannelsProps) {
</div>
<div class="muted">${props.lastSuccessAt ? formatAgo(props.lastSuccessAt) : "n/a"}</div>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${props.lastError}
</div>`
: nothing}
: nothing
}
<pre class="code-block" style="margin-top: 12px;">
${props.snapshot ? JSON.stringify(props.snapshot, null, 2) : "No snapshot yet."}
</pre>
@@ -101,27 +95,11 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return [
"whatsapp",
"telegram",
"discord",
"googlechat",
"slack",
"signal",
"imessage",
"nostr",
];
return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"];
}
function renderChannel(
key: ChannelKey,
props: ChannelsProps,
data: ChannelsChannelData,
) {
const accountCountLabel = renderChannelAccountCount(
key,
data.channelAccounts,
);
function renderChannel(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) {
const accountCountLabel = renderChannelAccountCount(key, data.channelAccounts);
switch (key) {
case "whatsapp":
return renderWhatsAppCard({
@@ -218,13 +196,14 @@ function renderGenericChannelCard(
<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>
@@ -239,13 +218,16 @@ function renderGenericChannelCard(
<span>${connected == null ? "n/a" : connected ? "Yes" : "No"}</span>
</div>
</div>
`}
`
}
${lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${lastError}
</div>`
: nothing}
: nothing
}
${renderChannelConfigSection({ channelId: key, props })}
</div>
@@ -259,10 +241,7 @@ function resolveChannelMetaMap(
return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry]));
}
function resolveChannelLabel(
snapshot: ChannelsStatusSnapshot | null,
key: string,
): string {
function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string {
const meta = resolveChannelMetaMap(snapshot)[key];
return meta?.label ?? snapshot?.channelLabels?.[key] ?? key;
}
@@ -316,13 +295,15 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
<span class="label">Last inbound</span>
<span>${account.lastInboundAt ? formatAgo(account.lastInboundAt) : "n/a"}</span>
</div>
${account.lastError
? html`
${
account.lastError
? html`
<div class="account-card-error">
${account.lastError}
</div>
`
: nothing}
: nothing
}
</div>
</div>
`;
+12 -12
View File
@@ -1,16 +1,16 @@
import type {
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
ChannelAccountSnapshot,
ChannelsStatusSnapshot,
ConfigUiHints,
DiscordStatus,
GoogleChatStatus,
IMessageStatus,
NostrProfile,
NostrStatus,
SignalStatus,
SlackStatus,
TelegramStatus,
WhatsAppStatus,
} from "../types";
import type { NostrProfileFormState } from "./channels.nostr-profile-form";
+17 -15
View File
@@ -39,9 +39,7 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Last connect</span>
<span>
${whatsapp?.lastConnectedAt
? formatAgo(whatsapp.lastConnectedAt)
: "n/a"}
${whatsapp?.lastConnectedAt ? formatAgo(whatsapp.lastConnectedAt) : "n/a"}
</span>
</div>
<div>
@@ -53,30 +51,34 @@ export function renderWhatsAppCard(params: {
<div>
<span class="label">Auth age</span>
<span>
${whatsapp?.authAgeMs != null
? formatDuration(whatsapp.authAgeMs)
: "n/a"}
${whatsapp?.authAgeMs != null ? formatDuration(whatsapp.authAgeMs) : "n/a"}
</span>
</div>
</div>
${whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
whatsapp?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${whatsapp.lastError}
</div>`
: nothing}
: nothing
}
${props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${
props.whatsappMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.whatsappMessage}
</div>`
: nothing}
: nothing
}
${props.whatsappQrDataUrl
? html`<div class="qr-wrap">
${
props.whatsappQrDataUrl
? html`<div class="qr-wrap">
<img src=${props.whatsappQrDataUrl} alt="WhatsApp QR" />
</div>`
: nothing}
: nothing
}
<div class="row" style="margin-top: 14px; flex-wrap: wrap;">
<button
+59 -58
View File
@@ -5,10 +5,7 @@ import type { SessionsListResult } from "../types";
import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import {
normalizeMessage,
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -108,10 +105,7 @@ function generateAttachmentId(): string {
return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function handlePaste(
e: ClipboardEvent,
props: ChatProps,
) {
function handlePaste(e: ClipboardEvent, props: ChatProps) {
const items = e.clipboardData?.items;
if (!items || !props.onAttachmentsChange) return;
@@ -165,9 +159,7 @@ function renderAttachmentPreview(props: ChatProps) {
type="button"
aria-label="Remove attachment"
@click=${() => {
const next = (props.attachments ?? []).filter(
(a) => a.id !== att.id,
);
const next = (props.attachments ?? []).filter((a) => a.id !== att.id);
props.onAttachmentsChange?.(next);
}}
>
@@ -184,9 +176,7 @@ export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
const canAbort = Boolean(props.canAbort && props.onAbort);
const activeSession = props.sessions?.sessions?.find(
(row) => row.key === props.sessionKey,
);
const activeSession = props.sessions?.sessions?.find((row) => row.key === props.sessionKey);
const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = props.showThinking && reasoningLevel !== "off";
const assistantIdentity = {
@@ -210,49 +200,56 @@ export function renderChat(props: ChatProps) {
aria-live="polite"
@scroll=${props.onChatScroll}
>
${props.loading ? html`<div class="muted">Loading chat…</div>` : nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
${
props.loading
? html`
<div class="muted">Loading chat</div>
`
: nothing
}
${repeat(
buildChatItems(props),
(item) => item.key,
(item) => {
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
assistantIdentity,
);
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
if (item.kind === "group") {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
});
}
return nothing;
})}
return nothing;
},
)}
</div>
`;
return html`
<section class="card chat">
${props.disabledReason
? html`<div class="callout">${props.disabledReason}</div>`
: nothing}
${props.disabledReason ? html`<div class="callout">${props.disabledReason}</div>` : nothing}
${props.error
? html`<div class="callout danger">${props.error}</div>`
: nothing}
${props.error ? html`<div class="callout danger">${props.error}</div>` : nothing}
${renderCompactionIndicator(props.compactionStatus)}
${props.focusMode
? html`
${
props.focusMode
? html`
<button
class="chat-focus-exit"
type="button"
@@ -263,7 +260,8 @@ export function renderChat(props: ChatProps) {
${icons.x}
</button>
`
: nothing}
: nothing
}
<div
class="chat-split-container ${sidebarOpen ? "chat-split-container--open" : ""}"
@@ -275,12 +273,12 @@ export function renderChat(props: ChatProps) {
${thread}
</div>
${sidebarOpen
? html`
${
sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@resize=${(e: CustomEvent) =>
props.onSplitRatioChange?.(e.detail.splitRatio)}
@resize=${(e: CustomEvent) => props.onSplitRatioChange?.(e.detail.splitRatio)}
></resizable-divider>
<div class="chat-sidebar">
${renderMarkdownSidebar({
@@ -294,11 +292,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">
@@ -306,10 +306,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"
@@ -325,7 +325,8 @@ export function renderChat(props: ChatProps) {
</div>
</div>
`
: nothing}
: nothing
}
<div class="chat-compose">
${renderAttachmentPreview(props)}
+5 -15
View File
@@ -47,8 +47,7 @@ function normalizeSchemaNode(
const nullable = Array.isArray(schema.type) && schema.type.includes("null");
const type =
schemaType(schema) ??
(schema.properties || schema.additionalProperties ? "object" : undefined);
schemaType(schema) ?? (schema.properties || schema.additionalProperties ? "object" : undefined);
normalized.type = type ?? schema.type;
normalized.nullable = nullable || schema.nullable;
@@ -73,24 +72,15 @@ function normalizeSchemaNode(
unsupported.add(pathLabel);
} else if (schema.additionalProperties === false) {
normalized.additionalProperties = false;
} else if (
schema.additionalProperties &&
typeof schema.additionalProperties === "object"
) {
} else if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (!isAnySchema(schema.additionalProperties as JsonSchema)) {
const res = normalizeSchemaNode(
schema.additionalProperties as JsonSchema,
[...path, "*"],
);
normalized.additionalProperties =
res.schema ?? (schema.additionalProperties as JsonSchema);
const res = normalizeSchemaNode(schema.additionalProperties as JsonSchema, [...path, "*"]);
normalized.additionalProperties = res.schema ?? (schema.additionalProperties as JsonSchema);
if (res.unsupportedPaths.length > 0) unsupported.add(pathLabel);
}
}
} else if (type === "array") {
const itemsSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
unsupported.add(pathLabel);
} else {
+162 -72
View File
@@ -28,11 +28,69 @@ function jsonValue(value: unknown): string {
// SVG Icons as template literals
const icons = {
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>`,
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" 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></svg>`,
edit: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>`,
chevronDown: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`,
plus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
minus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
trash: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
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>
</svg>
`,
edit: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
`,
};
export function renderNode(params: {
@@ -64,7 +122,7 @@ export function renderNode(params: {
if (schema.anyOf || schema.oneOf) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null")))
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (nonNull.length === 1) {
@@ -88,16 +146,18 @@ export function renderNode(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${literals.map((lit, idx) => html`
${literals.map(
(lit, idx) => html`
<button
type="button"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? 'active' : ''}"
class="cfg-segmented__btn ${lit === resolvedValue || String(lit) === String(resolvedValue) ? "active" : ""}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${String(lit)}
</button>
`)}
`,
)}
</div>
</div>
`;
@@ -109,11 +169,9 @@ export function renderNode(params: {
}
// Handle mixed primitive types
const primitiveTypes = new Set(
nonNull.map((variant) => schemaType(variant)).filter(Boolean)
);
const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
const normalizedTypes = new Set(
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v))
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v)),
);
if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) {
@@ -147,16 +205,18 @@ export function renderNode(params: {
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
<div class="cfg-segmented">
${options.map((opt) => html`
${options.map(
(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)}
>
${String(opt)}
</button>
`)}
`,
)}
</div>
</div>
`;
@@ -176,9 +236,14 @@ export function renderNode(params: {
// Boolean - toggle row
if (type === "boolean") {
const displayValue = typeof value === "boolean" ? value : typeof schema.default === "boolean" ? schema.default : false;
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html`
<label class="cfg-toggle-row ${disabled ? 'disabled' : ''}">
<label class="cfg-toggle-row ${disabled ? "disabled" : ""}">
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
@@ -266,7 +331,9 @@ function renderTextInput(params: {
onPatch(path, raw.trim());
}}
/>
${schema.default !== undefined ? html`
${
schema.default !== undefined
? html`
<button
type="button"
class="cfg-input__reset"
@@ -274,7 +341,9 @@ function renderTextInput(params: {
?disabled=${disabled}
@click=${() => onPatch(path, schema.default)}
></button>
` : nothing}
`
: nothing
}
</div>
</div>
`;
@@ -365,9 +434,11 @@ function renderSelect(params: {
}}
>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html`
${options.map(
(opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`)}
`,
)}
</select>
</div>
`;
@@ -390,9 +461,10 @@ function renderObject(params: {
const help = hint?.help ?? schema.description;
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
@@ -421,18 +493,22 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
})
}),
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
${
allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing
}
</div>
`;
}
@@ -455,18 +531,22 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
})
}),
)}
${allowExtra ? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
}) : nothing}
${
allowExtra
? renderMapField({
schema: additional as JsonSchema,
value: obj,
path,
hints,
unsupported,
disabled,
reservedKeys: reserved,
onPatch,
})
: nothing
}
</div>
</details>
`;
@@ -504,7 +584,7 @@ function renderArray(params: {
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? 's' : ''}</span>
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? "s" : ""}</span>
<button
type="button"
class="cfg-array__add"
@@ -520,13 +600,15 @@ function renderArray(params: {
</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`
${arr.map(
(item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
@@ -557,9 +639,11 @@ function renderArray(params: {
})}
</div>
</div>
`)}
`,
)}
</div>
`}
`
}
</div>
`;
}
@@ -603,9 +687,12 @@ function renderMapField(params: {
</button>
</div>
${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div>
` : html`
${
entries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
const valuePath = [...path, key];
@@ -631,8 +718,9 @@ function renderMapField(params: {
/>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
${
anySchema
? html`
<textarea
class="cfg-textarea cfg-textarea--sm"
placeholder="JSON value"
@@ -654,16 +742,17 @@ function renderMapField(params: {
}}
></textarea>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})}
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
unsupported,
disabled,
showLabel: false,
onPatch,
})
}
</div>
<button
type="button"
@@ -682,7 +771,8 @@ function renderMapField(params: {
`;
})}
</div>
`}
`
}
</div>
`;
}
+266 -71
View File
@@ -1,12 +1,7 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { icons } from "../icons";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared";
import { renderNode } from "./config-form.node";
export type ConfigFormProps = {
@@ -23,44 +18,237 @@ export type ConfigFormProps = {
// SVG Icons for section cards (Lucide-style)
const sectionIcons = {
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>`,
logging: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
browser: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4"></circle><line x1="21.17" y1="8" x2="12" y2="8"></line><line x1="3.95" y1="6.06" x2="8.54" y2="14"></line><line x1="10.88" y1="21.94" x2="15.46" y2="14"></line></svg>`,
ui: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>`,
models: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
bindings: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
broadcast: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path><circle cx="12" cy="12" r="2"></circle><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path></svg>`,
audio: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
session: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
cron: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
web: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
discovery: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
canvasHost: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
talk: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
plugins: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2v6"></path><path d="m4.93 10.93 4.24 4.24"></path><path d="M2 12h6"></path><path d="m4.93 13.07 4.24-4.24"></path><path d="M12 22v-6"></path><path d="m19.07 13.07-4.24-4.24"></path><path d="M22 12h-6"></path><path d="m19.07 10.93-4.24 4.24"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
env: {
label: "Environment Variables",
description: "Environment variables passed to the gateway process",
},
update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
auth: { label: "Authentication", description: "API keys and authentication profiles" },
channels: { label: "Channels", description: "Messaging channels (Telegram, Discord, Slack, etc.)" },
channels: {
label: "Channels",
description: "Messaging channels (Telegram, Discord, Slack, etc.)",
},
messages: { label: "Messages", description: "Message handling and routing settings" },
commands: { label: "Commands", description: "Custom slash commands" },
hooks: { label: "Hooks", description: "Webhooks and event hooks" },
@@ -142,12 +330,16 @@ function schemaMatches(schema: JsonSchema, query: string): boolean {
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;
@@ -168,9 +360,8 @@ export function renderConfigForm(props: ConfigFormProps) {
return true;
});
let subsectionContext:
| { sectionKey: string; subsectionKey: string; schema: JsonSchema }
| null = null;
let subsectionContext: { sectionKey: string; subsectionKey: string; schema: JsonSchema } | null =
null;
if (activeSection && activeSubsection && filteredEntries.length === 1) {
const sectionSchema = filteredEntries[0]?.[1];
if (
@@ -192,9 +383,7 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-empty">
<div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
${searchQuery ? `No settings match "${searchQuery}"` : "No settings in this section"}
</div>
</div>
`;
@@ -202,27 +391,30 @@ 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 as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
${
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 as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
? (sectionValue as Record<string, unknown>)[subsectionKey]
: undefined;
const id = `config-section-${sectionKey}-${subsectionKey}`;
return html`
<section class="config-section-card" id=${id}>
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(sectionKey)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing}
${
description
? html`<p class="config-section-card__desc">${description}</p>`
: nothing
}
</div>
</div>
<div class="config-section-card__content">
@@ -239,22 +431,24 @@ export function renderConfigForm(props: ConfigFormProps) {
</div>
</section>
`;
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
})()
: filteredEntries.map(([key, node]) => {
const meta = SECTION_META[key] ?? {
label: key.charAt(0).toUpperCase() + key.slice(1),
description: node.description ?? "",
};
return html`
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
${
meta.description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing
}
</div>
</div>
<div class="config-section-card__content">
@@ -271,7 +465,8 @@ export function renderConfigForm(props: ConfigFormProps) {
</div>
</section>
`;
})}
})
}
</div>
`;
}
+1 -4
View File
@@ -1,7 +1,4 @@
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
} from "./config-form.analyze";
export { analyzeConfigSchema, type ConfigSchemaAnalysis } from "./config-form.analyze";
export { renderNode } from "./config-form.node";
export { schemaType, type JsonSchema } from "./config-form.shared";
+24 -38
View File
@@ -59,11 +59,9 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
});
@@ -81,11 +79,9 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
});
@@ -102,16 +98,12 @@ describe("config view", () => {
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(true);
@@ -124,22 +116,18 @@ describe("config view", () => {
renderConfig({
...baseProps(),
formMode: "raw",
raw: "{\n gateway: { mode: \"local\" }\n}\n",
raw: '{\n gateway: { mode: "local" }\n}\n',
originalRaw: "{\n}\n",
}),
container,
);
const saveButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Save") as
| HTMLButtonElement
| undefined;
const applyButton = Array.from(
container.querySelectorAll("button"),
).find((btn) => btn.textContent?.trim() === "Apply") as
| HTMLButtonElement
| undefined;
const saveButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Save",
) as HTMLButtonElement | undefined;
const applyButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Apply",
) as HTMLButtonElement | undefined;
expect(saveButton).not.toBeUndefined();
expect(applyButton).not.toBeUndefined();
expect(saveButton?.disabled).toBe(false);
@@ -157,8 +145,8 @@ describe("config view", () => {
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim() === "Raw",
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Raw",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
@@ -183,8 +171,8 @@ describe("config view", () => {
container,
);
const btn = Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.trim() === "Gateway",
const btn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Gateway",
) as HTMLButtonElement | undefined;
expect(btn).toBeTruthy();
btn?.click();
@@ -202,9 +190,7 @@ describe("config view", () => {
container,
);
const input = container.querySelector(
".config-search__input",
) as HTMLInputElement | null;
const input = container.querySelector(".config-search__input") as HTMLInputElement | null;
expect(input).not.toBeNull();
if (!input) return;
input.value = "gateway";
+322 -105
View File
@@ -1,12 +1,7 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import {
hintForPath,
humanize,
schemaType,
type JsonSchema,
} from "./config-form.shared";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared";
export type ConfigProps = {
raw: string;
@@ -41,36 +36,230 @@ export type ConfigProps = {
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`,
env: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
update: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
agents: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path><circle cx="8" cy="14" r="1"></circle><circle cx="16" cy="14" r="1"></circle></svg>`,
auth: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>`,
channels: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
messages: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>`,
commands: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line></svg>`,
hooks: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
skills: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>`,
tools: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`,
gateway: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
wizard: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h0"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>`,
all: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
`,
env: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
`,
update: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
`,
agents: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"
></path>
<circle cx="8" cy="14" r="1"></circle>
<circle cx="16" cy="14" r="1"></circle>
</svg>
`,
auth: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
`,
channels: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
`,
messages: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
`,
commands: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
`,
hooks: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
`,
skills: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
></polygon>
</svg>
`,
tools: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`,
gateway: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
wizard: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 4V2"></path>
<path d="M15 16v-2"></path>
<path d="M8 9h2"></path>
<path d="M20 9h2"></path>
<path d="M17.8 11.8 19 13"></path>
<path d="M15 9h0"></path>
<path d="M17.8 6.2 19 5"></path>
<path d="m3 21 9-9"></path>
<path d="M12.2 6.2 11 5"></path>
</svg>
`,
// Additional sections
meta: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"></path><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path></svg>`,
logging: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`,
browser: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4"></circle><line x1="21.17" y1="8" x2="12" y2="8"></line><line x1="3.95" y1="6.06" x2="8.54" y2="14"></line><line x1="10.88" y1="21.94" x2="15.46" y2="14"></line></svg>`,
ui: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="3" y1="9" x2="21" y2="9"></line><line x1="9" y1="21" x2="9" y2="9"></line></svg>`,
models: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>`,
bindings: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect><rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect><line x1="6" y1="6" x2="6.01" y2="6"></line><line x1="6" y1="18" x2="6.01" y2="18"></line></svg>`,
broadcast: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path><circle cx="12" cy="12" r="2"></circle><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path></svg>`,
audio: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"></path><circle cx="6" cy="18" r="3"></circle><circle cx="18" cy="16" r="3"></circle></svg>`,
session: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>`,
cron: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>`,
web: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
discovery: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>`,
canvasHost: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>`,
talk: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
plugins: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6"></path><path d="m4.93 10.93 4.24 4.24"></path><path d="M2 12h6"></path><path d="m4.93 13.07 4.24-4.24"></path><path d="M12 22v-6"></path><path d="m19.07 13.07-4.24-4.24"></path><path d="M22 12h-6"></path><path d="m19.07 10.93-4.24 4.24"></path></svg>`,
default: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>`,
meta: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
`,
logging: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
`,
browser: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<circle cx="12" cy="12" r="4"></circle>
<line x1="21.17" y1="8" x2="12" y2="8"></line>
<line x1="3.95" y1="6.06" x2="8.54" y2="14"></line>
<line x1="10.88" y1="21.94" x2="15.46" y2="14"></line>
</svg>
`,
ui: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="9" y1="21" x2="9" y2="9"></line>
</svg>
`,
models: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path
d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</svg>
`,
bindings: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
<line x1="6" y1="6" x2="6.01" y2="6"></line>
<line x1="6" y1="18" x2="6.01" y2="18"></line>
</svg>
`,
broadcast: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
</svg>
`,
audio: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>
`,
session: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
`,
cron: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
`,
web: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
></path>
</svg>
`,
discovery: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
`,
canvasHost: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
`,
talk: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
<line x1="12" y1="19" x2="12" y2="23"></line>
<line x1="8" y1="23" x2="16" y2="23"></line>
</svg>
`,
plugins: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v6"></path>
<path d="m4.93 10.93 4.24 4.24"></path>
<path d="M2 12h6"></path>
<path d="m4.93 13.07 4.24-4.24"></path>
<path d="M12 22v-6"></path>
<path d="m19.07 13.07-4.24-4.24"></path>
<path d="M22 12h-6"></path>
<path d="m19.07 10.93-4.24 4.24"></path>
</svg>
`,
default: html`
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`,
};
// Section definitions
@@ -102,7 +291,10 @@ function getSectionIcon(key: string) {
return sidebarIcons[key as keyof typeof sidebarIcons] ?? sidebarIcons.default;
}
function resolveSectionMeta(key: string, schema?: JsonSchema): {
function resolveSectionMeta(
key: string,
schema?: JsonSchema,
): {
label: string;
description?: string;
} {
@@ -134,7 +326,7 @@ function resolveSubsections(params: {
function computeDiff(
original: Record<string, unknown> | null,
current: Record<string, unknown> | null
current: Record<string, unknown> | null,
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) return [];
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
@@ -182,22 +374,19 @@ function truncateValue(value: unknown, maxLen = 40): string {
}
export function renderConfig(props: ConfigProps) {
const validity =
props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const validity = props.valid == null ? "unknown" : props.valid ? "valid" : "invalid";
const analysis = analyzeConfigSchema(props.schema);
const formUnsafe = analysis.schema
? analysis.unsupportedPaths.length > 0
: false;
const formUnsafe = analysis.schema ? analysis.unsupportedPaths.length > 0 : false;
// Get available sections from schema
const schemaProps = analysis.schema?.properties ?? {};
const availableSections = SECTIONS.filter(s => s.key in schemaProps);
const availableSections = SECTIONS.filter((s) => s.key in schemaProps);
// Add any sections in schema but not in our list
const knownKeys = new Set(SECTIONS.map(s => s.key));
const knownKeys = new Set(SECTIONS.map((s) => s.key));
const extraSections = Object.keys(schemaProps)
.filter(k => !knownKeys.has(k))
.map(k => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
.filter((k) => !knownKeys.has(k))
.map((k) => ({ key: k, label: k.charAt(0).toUpperCase() + k.slice(1) }));
const allSections = [...availableSections, ...extraSections];
@@ -216,27 +405,22 @@ export function renderConfig(props: ConfigProps) {
})
: [];
const allowSubnav =
props.formMode === "form" &&
Boolean(props.activeSection) &&
subsections.length > 0;
props.formMode === "form" && Boolean(props.activeSection) && subsections.length > 0;
const isAllSubsection = props.activeSubsection === ALL_SUBSECTION;
const effectiveSubsection = props.searchQuery
? null
: isAllSubsection
? null
: props.activeSubsection ?? (subsections[0]?.key ?? null);
: (props.activeSubsection ?? subsections[0]?.key ?? null);
// Compute diff for showing changes (works for both form and raw modes)
const diff = props.formMode === "form"
? computeDiff(props.originalValue, props.formValue)
: [];
const diff = props.formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = props.formMode === "raw" && props.raw !== props.originalRaw;
const hasChanges = props.formMode === "form" ? diff.length > 0 : hasRawChanges;
// Save/apply buttons require actual changes to be enabled.
// Note: formUnsafe warns about unsupported schema paths but shouldn't block saving.
const canSaveForm =
Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSaveForm = Boolean(props.formValue) && !props.loading && Boolean(analysis.schema);
const canSave =
props.connected &&
!props.saving &&
@@ -272,12 +456,16 @@ export function renderConfig(props: ConfigProps) {
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery ? html`
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>×</button>
` : nothing}
`
: nothing
}
</div>
<!-- Section nav -->
@@ -289,7 +477,8 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${sidebarIcons.all}</span>
<span class="config-nav__label">All Settings</span>
</button>
${allSections.map(section => html`
${allSections.map(
(section) => html`
<button
class="config-nav__item ${props.activeSection === section.key ? "active" : ""}"
@click=${() => props.onSectionChange(section.key)}
@@ -297,7 +486,8 @@ export function renderConfig(props: ConfigProps) {
<span class="config-nav__icon">${getSectionIcon(section.key)}</span>
<span class="config-nav__label">${section.label}</span>
</button>
`)}
`,
)}
</nav>
<!-- Mode toggle at bottom -->
@@ -325,11 +515,15 @@ export function renderConfig(props: ConfigProps) {
<!-- Action bar -->
<div class="config-actions">
<div class="config-actions__left">
${hasChanges ? html`
${
hasChanges
? html`
<span class="config-changes-badge">${props.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">
<button class="btn btn--sm" ?disabled=${props.loading} @click=${props.onReload}>
@@ -360,7 +554,9 @@ export function renderConfig(props: ConfigProps) {
</div>
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${hasChanges && props.formMode === "form" ? html`
${
hasChanges && props.formMode === "form"
? html`
<details class="config-diff">
<summary class="config-diff__summary">
<span>View ${diff.length} pending change${diff.length !== 1 ? "s" : ""}</span>
@@ -369,7 +565,8 @@ export function renderConfig(props: ConfigProps) {
</svg>
</summary>
<div class="config-diff__content">
${diff.map(change => html`
${diff.map(
(change) => html`
<div class="config-diff__item">
<div class="config-diff__path">${change.path}</div>
<div class="config-diff__values">
@@ -378,27 +575,35 @@ export function renderConfig(props: ConfigProps) {
<span class="config-diff__to">${truncateValue(change.to)}</span>
</div>
</div>
`)}
`,
)}
</div>
</details>
` : nothing}
`
: nothing
}
${activeSectionMeta && props.formMode === "form"
? html`
${
activeSectionMeta && props.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}</div>`
: nothing}
${
activeSectionMeta.description
? html`<div class="config-section-hero__desc">${activeSectionMeta.description}</div>`
: nothing
}
</div>
</div>
`
: nothing}
: nothing
}
${allowSubnav
? html`
${
allowSubnav
? html`
<div class="config-subnav">
<button
class="config-subnav__item ${effectiveSubsection === null ? "active" : ""}"
@@ -421,36 +626,45 @@ export function renderConfig(props: ConfigProps) {
)}
</div>
`
: nothing}
: nothing
}
<!-- Form content -->
<div class="config-content">
${props.formMode === "form"
? html`
${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,
})}
${formUnsafe
? html`<div class="callout danger" style="margin-top: 12px;">
Form view can't safely edit some fields.
Use Raw to avoid losing config entries.
</div>`
: nothing}
${
props.formMode === "form"
? html`
${
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,
})
}
${
formUnsafe
? html`
<div class="callout danger" style="margin-top: 12px">
Form view can't safely edit some fields. Use Raw to avoid losing config entries.
</div>
`
: nothing
}
`
: html`
: html`
<label class="field config-raw-field">
<span>Raw JSON5</span>
<textarea
@@ -459,14 +673,17 @@ export function renderConfig(props: ConfigProps) {
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>
`}
`
}
</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>
`;
+41 -31
View File
@@ -63,11 +63,7 @@ export function renderCron(props: CronProps) {
<div class="stat">
<div class="stat-label">Enabled</div>
<div class="stat-value">
${props.status
? props.status.enabled
? "Yes"
: "No"
: "n/a"}
${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"}
</div>
</div>
<div class="stat">
@@ -131,7 +127,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.scheduleKind}
@change=${(e: Event) =>
props.onFormChange({
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
scheduleKind: (e.target as HTMLSelectElement)
.value as CronFormState["scheduleKind"],
})}
>
<option value="every">Every</option>
@@ -148,7 +145,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.sessionTarget}
@change=${(e: Event) =>
props.onFormChange({
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
sessionTarget: (e.target as HTMLSelectElement)
.value as CronFormState["sessionTarget"],
})}
>
<option value="main">Main</option>
@@ -174,7 +172,8 @@ export function renderCron(props: CronProps) {
.value=${props.form.payloadKind}
@change=${(e: Event) =>
props.onFormChange({
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
payloadKind: (e.target as HTMLSelectElement)
.value as CronFormState["payloadKind"],
})}
>
<option value="systemEvent">System event</option>
@@ -193,8 +192,9 @@ export function renderCron(props: CronProps) {
rows="4"
></textarea>
</label>
${props.form.payloadKind === "agentTurn"
? html`
${
props.form.payloadKind === "agentTurn"
? html`
<div class="form-grid" style="margin-top: 12px;">
<label class="field checkbox">
<span>Deliver</span>
@@ -212,9 +212,10 @@ export function renderCron(props: CronProps) {
<select
.value=${props.form.channel || "last"}
@change=${(e: Event) =>
props.onFormChange({
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
})}
props.onFormChange({
channel: (e.target as HTMLSelectElement)
.value as CronFormState["channel"],
})}
>
${channelOptions.map(
(channel) =>
@@ -243,8 +244,9 @@ export function renderCron(props: CronProps) {
})}
/>
</label>
${props.form.sessionTarget === "isolated"
? html`
${
props.form.sessionTarget === "isolated"
? html`
<label class="field">
<span>Post to main prefix</span>
<input
@@ -256,10 +258,12 @@ export function renderCron(props: CronProps) {
/>
</label>
`
: nothing}
: nothing
}
</div>
`
: nothing}
: nothing
}
<div class="row" style="margin-top: 14px;">
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
${props.busy ? "Saving…" : "Add job"}
@@ -271,31 +275,37 @@ export function renderCron(props: CronProps) {
<section class="card" style="margin-top: 18px;">
<div class="card-title">Jobs</div>
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
${props.jobs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
: html`
${
props.jobs.length === 0
? html`
<div class="muted" style="margin-top: 12px">No jobs yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;">
${props.jobs.map((job) => renderJob(job, props))}
</div>
`}
`
}
</section>
<section class="card" style="margin-top: 18px;">
<div class="card-title">Run history</div>
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
${props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px;">
Select a job to inspect run history.
</div>
`
: props.runs.length === 0
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
: html`
${
props.runsJobId == null
? html`
<div class="muted" style="margin-top: 12px">Select a job to inspect run history.</div>
`
: props.runs.length === 0
? html`
<div class="muted" style="margin-top: 12px">No runs yet.</div>
`
: html`
<div class="list" style="margin-top: 12px;">
${props.runs.map((entry) => renderRun(entry))}
</div>
`}
`
}
</section>
`;
}
+25 -20
View File
@@ -31,11 +31,7 @@ export function renderDebug(props: DebugProps) {
const info = securitySummary?.info ?? 0;
const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success";
const securityLabel =
critical > 0
? `${critical} critical`
: warn > 0
? `${warn} warnings`
: "No critical issues";
critical > 0 ? `${critical} critical` : warn > 0 ? `${warn} warnings` : "No critical issues";
return html`
<section class="grid grid-cols-2">
@@ -52,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>
@@ -79,8 +77,7 @@ export function renderDebug(props: DebugProps) {
<span>Method</span>
<input
.value=${props.callMethod}
@input=${(e: Event) =>
props.onCallMethodChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onCallMethodChange((e.target as HTMLInputElement).value)}
placeholder="system-presence"
/>
</label>
@@ -97,14 +94,18 @@ 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
? 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}
: nothing
}
${
props.callResult
? html`<pre class="code-block" style="margin-top: 12px;">${props.callResult}</pre>`
: nothing
}
</div>
</section>
@@ -121,9 +122,12 @@ export function renderDebug(props: DebugProps) {
<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" style="margin-top: 12px;">
${props.eventLog.map(
(evt) => html`
@@ -139,7 +143,8 @@ export function renderDebug(props: DebugProps) {
`,
)}
</div>
`}
`
}
</section>
`;
}
+10 -6
View File
@@ -32,9 +32,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">
@@ -46,9 +48,11 @@ export function renderExecApprovalPrompt(state: AppViewState) {
${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"
+24 -19
View File
@@ -23,30 +23,35 @@ export function renderInstances(props: InstancesProps) {
${props.loading ? "Loading…" : "Refresh"}
</button>
</div>
${props.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${
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;">
: nothing
}
${
props.statusMessage
? html`<div class="callout" style="margin-top: 12px;">
${props.statusMessage}
</div>`
: nothing}
: 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))}
${
props.entries.length === 0
? html`
<div class="muted">No instances reported yet.</div>
`
: props.entries.map((entry) => renderEntry(entry))
}
</div>
</section>
`;
}
function renderEntry(entry: PresenceEntry) {
const lastInput =
entry.lastInputSeconds != null
? `${entry.lastInputSeconds}s ago`
: "n/a";
const lastInput = entry.lastInputSeconds != null ? `${entry.lastInputSeconds}s ago` : "n/a";
const mode = entry.mode ?? "unknown";
const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : [];
const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : [];
@@ -66,12 +71,12 @@ function renderEntry(entry: PresenceEntry) {
${roles.map((role) => html`<span class="chip">${role}</span>`)}
${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.deviceFamily ? html`<span class="chip">${entry.deviceFamily}</span>` : nothing}
${
entry.modelIdentifier
? html`<span class="chip">${entry.modelIdentifier}</span>`
: nothing
}
${entry.version ? html`<span class="chip">${entry.version}</span>` : nothing}
</div>
</div>
+32 -19
View File
@@ -60,7 +60,11 @@ export function renderLogs(props: LogsProps) {
<button
class="btn"
?disabled=${filtered.length === 0}
@click=${() => props.onExport(filtered.map((entry) => entry.raw), exportLabel)}
@click=${() =>
props.onExport(
filtered.map((entry) => entry.raw),
exportLabel,
)}
>
Export ${exportLabel}
</button>
@@ -72,8 +76,7 @@ export function renderLogs(props: LogsProps) {
<span>Filter</span>
<input
.value=${props.filterText}
@input=${(e: Event) =>
props.onFilterTextChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onFilterTextChange((e.target as HTMLInputElement).value)}
placeholder="Search logs"
/>
</label>
@@ -104,23 +107,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>
@@ -128,7 +140,8 @@ export function renderLogs(props: LogsProps) {
<div class="log-message mono">${entry.message ?? entry.raw}</div>
</div>
`,
)}
)
}
</div>
</section>
`;
+9 -5
View File
@@ -21,16 +21,20 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
</button>
</div>
<div class="sidebar-content">
${props.error
? html`
${
props.error
? html`
<div class="callout danger">${props.error}</div>
<button @click=${props.onViewRawText} class="btn" style="margin-top: 12px;">
View Raw Text
</button>
`
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`<div class="muted">No content available</div>`}
: props.content
? html`<div class="sidebar-markdown">${unsafeHTML(toSanitizedMarkdownHtml(props.content))}</div>`
: html`
<div class="muted">No content available</div>
`
}
</div>
</div>
`;
+164 -114
View File
@@ -68,9 +68,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>
`;
@@ -91,25 +95,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>
`;
@@ -156,14 +170,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>
`;
@@ -183,16 +201,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>
`;
@@ -389,21 +409,17 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const targetNodes = resolveExecApprovalsNodes(props.nodes);
const target = props.execApprovalsTarget;
let targetNodeId =
target === "node" && props.execApprovalsTargetNodeId
? props.execApprovalsTargetNodeId
: null;
target === "node" && props.execApprovalsTargetNodeId ? props.execApprovalsTargetNodeId : null;
if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) {
targetNodeId = null;
}
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
? ((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ??
null
? (((form?.agents ?? {})[selectedScope] as Record<string, unknown> | undefined) ?? null)
: null;
const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist)
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ??
[])
? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? [])
: [];
return {
ready,
@@ -450,20 +466,25 @@ 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.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.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">
@@ -493,19 +514,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>
`;
}
@@ -533,20 +561,24 @@ function renderExecApprovals(state: ExecApprovalsState) {
${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>
`;
}
@@ -583,8 +615,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
@@ -608,12 +641,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>
`;
}
@@ -652,13 +690,10 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope];
const agentSecurity = typeof agent.security === "string" ? agent.security : undefined;
const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined;
const agentAskFallback =
typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : agentSecurity ?? "__default__";
const askValue = isDefaults ? defaults.ask : agentAsk ?? "__default__";
const askFallbackValue = isDefaults
? defaults.askFallback
: agentAskFallback ?? "__default__";
const agentAskFallback = typeof agent.askFallback === "string" ? agent.askFallback : undefined;
const securityValue = isDefaults ? defaults.security : (agentSecurity ?? "__default__");
const askValue = isDefaults ? defaults.ask : (agentAsk ?? "__default__");
const askFallbackValue = isDefaults ? defaults.askFallback : (agentAskFallback ?? "__default__");
const autoOverride =
typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined;
const autoEffective = autoOverride ?? defaults.autoAllowSkills;
@@ -670,9 +705,7 @@ function renderExecApprovalsPolicy(state: ExecApprovalsState) {
<div class="list-main">
<div class="list-title">Security</div>
<div class="list-sub">
${isDefaults
? "Default security mode."
: `Default: ${defaults.security}.`}
${isDefaults ? "Default security mode." : `Default: ${defaults.security}.`}
</div>
</div>
<div class="list-meta">
@@ -690,11 +723,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
@@ -731,11 +766,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
@@ -754,9 +791,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">
@@ -774,11 +813,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
@@ -797,11 +838,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">
@@ -817,15 +860,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>
@@ -853,11 +898,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>
`;
}
@@ -868,12 +915,8 @@ function renderAllowlistEntry(
index: number,
) {
const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never";
const lastCommand = entry.lastUsedCommand
? clampText(entry.lastUsedCommand, 120)
: null;
const lastPath = entry.lastResolvedPath
? clampText(entry.lastResolvedPath, 120)
: null;
const lastCommand = entry.lastUsedCommand ? clampText(entry.lastUsedCommand, 120) : null;
const lastPath = entry.lastResolvedPath ? clampText(entry.lastResolvedPath, 120) : null;
return html`
<div class="list-item">
<div class="list-main">
@@ -926,9 +969,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">
@@ -973,18 +1018,24 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` });
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}
function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): ExecApprovalsTargetNode[] {
function resolveExecApprovalsNodes(
nodes: Array<Record<string, unknown>>,
): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) => String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
(cmd) =>
String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) continue;
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
@@ -993,7 +1044,10 @@ function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): ExecA
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` });
list.push({
id: nodeId,
label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}`,
});
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
@@ -1035,9 +1089,7 @@ function resolveAgentBindings(config: Record<string, unknown> | null): {
const toolsEntry = (record.tools ?? {}) as Record<string, unknown>;
const execEntry = (toolsEntry.exec ?? {}) as Record<string, unknown>;
const binding =
typeof execEntry.node === "string" && execEntry.node.trim()
? execEntry.node.trim()
: null;
typeof execEntry.node === "string" && execEntry.node.trim() ? execEntry.node.trim() : null;
agents.push({
id,
name: name || undefined,
@@ -1077,9 +1129,7 @@ function renderNode(node: Record<string, unknown>) {
${connected ? "connected" : "offline"}
</span>
${caps.slice(0, 12).map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands
.slice(0, 8)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
${commands.slice(0, 8).map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>
</div>
</div>
+23 -28
View File
@@ -28,9 +28,7 @@ export function renderOverview(props: OverviewProps) {
| { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
| undefined;
const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
const tick = snapshot?.policy?.tickIntervalMs
? `${snapshot.policy.tickIntervalMs}ms`
: "n/a";
const tick = snapshot?.policy?.tickIntervalMs ? `${snapshot.policy.tickIntervalMs}ms` : "n/a";
const authHint = (() => {
if (props.connected || !props.lastError) return null;
const lower = props.lastError.toLowerCase();
@@ -40,13 +38,13 @@ export function renderOverview(props: OverviewProps) {
const hasPassword = Boolean(props.password.trim());
if (!hasToken && !hasPassword) {
return html`
<div class="muted" style="margin-top: 8px;">
<div class="muted" style="margin-top: 8px">
This gateway requires auth. Add a token or password, then click Connect.
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<span class="mono">openclaw dashboard --no-open</span> tokenized URL<br />
<span class="mono">openclaw doctor --generate-gateway-token</span> set token
</div>
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
@@ -60,11 +58,10 @@ export function renderOverview(props: OverviewProps) {
`;
}
return html`
<div class="muted" style="margin-top: 8px;">
<div class="muted" style="margin-top: 8px">
Auth failed. Re-copy a tokenized URL with
<span class="mono">openclaw dashboard --no-open</span>, or update the token,
then click Connect.
<div style="margin-top: 6px;">
<span class="mono">openclaw dashboard --no-open</span>, or update the token, then click Connect.
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.openclaw.ai/web/dashboard"
@@ -86,14 +83,14 @@ export function renderOverview(props: OverviewProps) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;">
<div class="muted" style="margin-top: 8px">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or open
<span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
</div>
<div style="margin-top: 6px;">
<div style="margin-top: 6px">
<a
class="session-link"
href="https://docs.openclaw.ai/gateway/tailscale"
@@ -195,21 +192,23 @@ export function renderOverview(props: OverviewProps) {
<div class="stat">
<div class="stat-label">Last Channels Refresh</div>
<div class="stat-value">
${props.lastChannelsRefresh
? formatAgo(props.lastChannelsRefresh)
: "n/a"}
${props.lastChannelsRefresh ? formatAgo(props.lastChannelsRefresh) : "n/a"}
</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>
${authHint ?? ""}
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>`}
: html`
<div class="callout" style="margin-top: 14px">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
</div>
`
}
</div>
</section>
@@ -227,11 +226,7 @@ export function renderOverview(props: OverviewProps) {
<div class="card stat-card">
<div class="stat-label">Cron</div>
<div class="stat-value">
${props.cronEnabled == null
? "n/a"
: props.cronEnabled
? "Enabled"
: "Disabled"}
${props.cronEnabled == null ? "n/a" : props.cronEnabled ? "Enabled" : "Disabled"}
</div>
<div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
</div>
+20 -16
View File
@@ -141,9 +141,11 @@ export function renderSessions(props: SessionsProps) {
</label>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${
props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing
}
<div class="muted" style="margin-top: 12px;">
${props.result ? `Store: ${props.result.path}` : ""}
@@ -161,11 +163,15 @@ export function renderSessions(props: SessionsProps) {
<div>Reasoning</div>
<div>Actions</div>
</div>
${rows.length === 0
? html`<div class="muted">No sessions found.</div>`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)}
${
rows.length === 0
? html`
<div class="muted">No sessions found.</div>
`
: rows.map((row) =>
renderRow(row, props.basePath, props.onPatch, props.onDelete, props.loading),
)
}
</div>
</section>
`;
@@ -193,9 +199,9 @@ function renderRow(
return html`
<div class="table-row">
<div class="mono">${canLink
? html`<a href=${chatUrl} class="session-link">${displayName}</a>`
: displayName}</div>
<div class="mono">${
canLink ? html`<a href=${chatUrl} class="session-link">${displayName}</a>` : displayName
}</div>
<div>
<input
.value=${row.label ?? ""}
@@ -221,9 +227,7 @@ function renderRow(
});
}}
>
${thinkLevels.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
)}
${thinkLevels.map((level) => html`<option value=${level}>${level || "inherit"}</option>`)}
</select>
</div>
<div>
@@ -249,8 +253,8 @@ function renderRow(
onPatch(row.key, { reasoningLevel: value || null });
}}
>
${REASONING_LEVELS.map((level) =>
html`<option value=${level}>${level || "inherit"}</option>`,
${REASONING_LEVELS.map(
(level) => html`<option value=${level}>${level || "inherit"}</option>`,
)}
</select>
</div>
+49 -33
View File
@@ -25,10 +25,7 @@ export function renderSkills(props: SkillsProps) {
const filter = props.filter.trim().toLowerCase();
const filtered = filter
? skills.filter((skill) =>
[skill.name, skill.description, skill.source]
.join(" ")
.toLowerCase()
.includes(filter),
[skill.name, skill.description, skill.source].join(" ").toLowerCase().includes(filter),
)
: skills;
@@ -49,25 +46,30 @@ export function renderSkills(props: SkillsProps) {
<span>Filter</span>
<input
.value=${props.filter}
@input=${(e: Event) =>
props.onFilterChange((e.target as HTMLInputElement).value)}
@input=${(e: Event) => props.onFilterChange((e.target as HTMLInputElement).value)}
placeholder="Search skills"
/>
</label>
<div class="muted">${filtered.length} shown</div>
</div>
${props.error
? html`<div class="callout danger" style="margin-top: 12px;">${props.error}</div>`
: nothing}
${
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;">No skills found.</div>`
: html`
${
filtered.length === 0
? html`
<div class="muted" style="margin-top: 16px">No skills found.</div>
`
: html`
<div class="list" style="margin-top: 16px;">
${filtered.map((skill) => renderSkill(skill, props))}
</div>
`}
`
}
</section>
`;
}
@@ -76,8 +78,7 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
const busy = props.busyKey === skill.skillKey;
const apiKey = props.edits[skill.skillKey] ?? "";
const message = props.messages[skill.skillKey] ?? null;
const canInstall =
skill.install.length > 0 && skill.missing.bins.length > 0;
const canInstall = skill.install.length > 0 && skill.missing.bins.length > 0;
const missing = [
...skill.missing.bins.map((b) => `bin:${b}`),
...skill.missing.env.map((e) => `env:${e}`),
@@ -99,22 +100,32 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<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>
${missing.length > 0
? html`
${
missing.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Missing: ${missing.join(", ")}
</div>
`
: nothing}
${reasons.length > 0
? html`
: nothing
}
${
reasons.length > 0
? html`
<div class="muted" style="margin-top: 6px;">
Reason: ${reasons.join(", ")}
</div>
`
: nothing}
: nothing
}
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; flex-wrap: wrap;">
@@ -125,19 +136,21 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
>
${skill.disabled ? "Enable" : "Disable"}
</button>
${canInstall
? html`<button
${
canInstall
? html`<button
class="btn"
?disabled=${busy}
@click=${() =>
props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
@click=${() => props.onInstall(skill.skillKey, skill.name, skill.install[0].id)}
>
${busy ? "Installing…" : skill.install[0].label}
</button>`
: nothing}
: nothing
}
</div>
${message
? html`<div
${
message
? html`<div
class="muted"
style="margin-top: 8px; color: ${
message.kind === "error"
@@ -147,9 +160,11 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
>
${message.message}
</div>`
: nothing}
${skill.primaryEnv
? html`
: nothing
}
${
skill.primaryEnv
? html`
<div class="field" style="margin-top: 10px;">
<span>API key</span>
<input
@@ -168,7 +183,8 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
Save key
</button>
`
: nothing}
: nothing
}
</div>
</div>
`;